fix(client): work through the findings from the coverage pass

214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
This commit is contained in:
Maurice
2026-07-29 11:57:24 +02:00
committed by Maurice
parent a4bf815d24
commit 72a82b3c44
425 changed files with 3155 additions and 1968 deletions
+1
View File
@@ -51,6 +51,7 @@ yarn-error.log*
# Coverage # Coverage
coverage coverage
coverage-*/
*.lcov *.lcov
.nyc_output .nyc_output
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+31 -3
View File
@@ -1,4 +1,4 @@
// FE-APIWIRE-001 to FE-APIWIRE-035 // FE-APIWIRE-001 to FE-APIWIRE-036
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { AxiosError, type AxiosAdapter, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios' import { AxiosError, type AxiosAdapter, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { http, HttpResponse } from 'msw' import { http, HttpResponse } from 'msw'
@@ -184,6 +184,20 @@ describe('client > rate-limit translation', () => {
const err = await captureError(() => apiClient.get('/limited')) const err = await captureError(() => apiClient.get('/limited'))
expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' }) expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' })
}) })
it('FE-APIWIRE-035: an array 429 body is replaced, not grafted onto', async () => {
server.use(http.get('/api/limited', () => HttpResponse.json([{ field: 'email' }], { status: 429 })))
const err = await captureError(() => apiClient.get('/limited'))
expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' })
})
it('FE-APIWIRE-036: Catalan, Greek and Vietnamese have their own 429 message', async () => {
for (const lang of ['ca', 'gr', 'vi']) {
localStorage.setItem('app_language', lang)
const err = await captureError(() => apiClient.get('/limited'))
expect(err.message).not.toBe('Too many attempts. Please try again later.')
}
})
}) })
describe('client > proxy auth challenges', () => { describe('client > proxy auth challenges', () => {
@@ -398,6 +412,7 @@ describe('client > adminApi.llmLocalPull', () => {
read: async () => (i < chunks.length read: async () => (i < chunks.length
? { done: false, value: encoder.encode(chunks[i++]) } ? { done: false, value: encoder.encode(chunks[i++]) }
: { done: true, value: undefined }), : { done: true, value: undefined }),
cancel: async () => {},
}), }),
}, },
} as unknown as Response } as unknown as Response
@@ -450,13 +465,26 @@ describe('client > adminApi.llmLocalPull', () => {
.rejects.toThrow('Pull failed (500)') .rejects.toThrow('Pull failed (500)')
}) })
it('FE-APIWIRE-033: a 200 without a readable body is treated as a failure', async () => { it('FE-APIWIRE-036: a throw from onProgress aborts the pull', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n{"status":"success"}\n',
]))
const onProgress = vi.fn((p: { error?: string }) => {
if (p.error) throw new Error(p.error)
})
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', onProgress))
.rejects.toThrow('manifest not found')
expect(onProgress).toHaveBeenCalledTimes(2)
})
it('FE-APIWIRE-033: a 200 without a readable body reports the missing stream', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
ok: true, status: 200, body: null, ok: true, status: 200, body: null,
json: async () => ({}), json: async () => ({}),
} as unknown as Response) } as unknown as Response)
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn())) await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn()))
.rejects.toThrow('Pull failed (200)') .rejects.toThrow('Pull returned no progress stream')
}) })
}) })
+29 -13
View File
@@ -105,6 +105,9 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.', ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
uk: 'Занадто багато спроб. Спробуйте пізніше.', uk: 'Занадто багато спроб. Спробуйте пізніше.',
sv: 'För många försök. Prova igen senare.', sv: 'För många försök. Prova igen senare.',
ca: 'Massa intents. Torneu-ho a provar més tard.',
gr: 'Πάρα πολλές προσπάθειες. Δοκιμάστε ξανά αργότερα.',
vi: 'Quá nhiều lần thử. Vui lòng thử lại sau.',
} }
function translateRateLimit(): string { function translateRateLimit(): string {
@@ -228,9 +231,11 @@ apiClient.interceptors.response.use(
} }
if (error.response?.status === 429) { if (error.response?.status === 429) {
const translated = translateRateLimit() const translated = translateRateLimit()
const data = error.response.data as { error?: string } | undefined const data = error.response.data
if (data && typeof data === 'object') { // Only a plain object body carries an `error` field worth overwriting;
data.error = translated // an array (a validation-error list) or a string is replaced outright.
if (data && typeof data === 'object' && !Array.isArray(data)) {
(data as { error?: string }).error = translated
} else { } else {
error.response.data = { error: translated } error.response.data = { error: translated }
} }
@@ -551,24 +556,35 @@ export const adminApi = {
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseUrl, model }), body: JSON.stringify({ baseUrl, model }),
}) })
if (!res.ok || !res.body) { if (!res.ok) {
let msg = `Pull failed (${res.status})` let msg = `Pull failed (${res.status})`
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ } try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
throw new Error(msg) throw new Error(msg)
} }
// An accepted request without a stream can't be followed to completion.
if (!res.body) throw new Error('Pull returned no progress stream')
const reader = res.body.getReader() const reader = res.body.getReader()
const dec = new TextDecoder() const dec = new TextDecoder()
let buf = '' let buf = ''
for (;;) { try {
const { done, value } = await reader.read() for (;;) {
if (done) break const { done, value } = await reader.read()
buf += dec.decode(value, { stream: true }) if (done) break
const lines = buf.split('\n') buf += dec.decode(value, { stream: true })
buf = lines.pop() ?? '' // split() always yields at least one element; the last one is the
for (const line of lines) { // trailing (possibly partial) line carried into the next chunk.
if (!line.trim()) continue const lines = buf.split('\n')
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ } buf = lines.pop()!
for (const line of lines) {
if (!line.trim()) continue
// Only the parse is swallowed — a throw from onProgress aborts the pull.
let frame: { status?: string; total?: number; completed?: number; error?: string }
try { frame = JSON.parse(line) } catch { continue }
onProgress(frame)
}
} }
} finally {
reader.cancel().catch(() => {})
} }
}, },
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
@@ -560,7 +560,7 @@ describe('AddonManager', () => {
}); });
}); });
it('FE-ADMIN-ADDON-027: an error frame in the pull stream is tolerated and the row returns to idle', async () => { it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
addonsRoute([llmAddon({ provider: 'local' })]), addonsRoute([llmAddon({ provider: 'local' })]),
@@ -575,6 +575,8 @@ describe('AddonManager', () => {
await screen.findByText('No models installed yet — pull one below.'); await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' })); await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('manifest not found');
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled()); await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument(); expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
}); });
@@ -1242,6 +1242,21 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
expect(screen.queryByText('Setup')).not.toBeInTheDocument() expect(screen.queryByText('Setup')).not.toBeInTheDocument()
}) })
it('FE-COMP-PLUGINS-PANEL-043b: a manifest that omits the lists still renders the modal', async () => {
const detail = manifestDetail({ size: 0 })
delete (detail.manifest as Record<string, unknown>).egress
delete (detail.manifest as Record<string, unknown>).settings
delete (detail.manifest as Record<string, unknown>).permissions
discoverWith({}, detail)
render(<AdminPluginsPanel />)
await clickDiscover()
fireEvent.click(await screen.findByText('Gotify'))
expect(await screen.findByText('Needs no special access.')).toBeInTheDocument()
expect(screen.queryByText('Connects to')).not.toBeInTheDocument()
expect(screen.queryByText('Setup')).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-PANEL-044: an incompatible entry explains the blocked button in the modal body', async () => { it('FE-COMP-PLUGINS-PANEL-044: an incompatible entry explains the blocked button in the modal body', async () => {
discoverWith({ compatible: false, latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' }) discoverWith({ compatible: false, latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })
render(<AdminPluginsPanel />) render(<AdminPluginsPanel />)
@@ -96,15 +96,19 @@ interface RegistryDetail extends RegistryItem {
size: number | null size: number | null
publishedAt: string | null publishedAt: string | null
manifest: { manifest: {
permissions: string[] // Optional because a registry may omit an empty list — the detail view has to
egress: string[] // degrade rather than throw halfway through its render.
permissions?: string[]
egress?: string[]
/** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */ /** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */
operatorEgress?: boolean operatorEgress?: boolean
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }> settings?: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
license: string | null license: string | null
icon: string | null icon: string | null
requiredAddons?: string[] requiredAddons?: string[]
pluginDependencies?: PluginDep[] pluginDependencies?: PluginDep[]
/** Display slice of the manifest's capabilities — drives the same chips as an installed row. */
capabilities?: { widget?: { slot?: string }; tripPage?: { replaces?: string[] } }
} | null } | null
} }
@@ -300,13 +304,8 @@ function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: s
return { blocked: true, label: t('admin.plugins.incompatible'), title } return { blocked: true, label: t('admin.plugins.incompatible'), title }
} }
function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) { function ReviewedBadge({ t }: { t: T }) {
if (compact) return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} /> return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-success-soft text-success">
<ShieldCheck size={11} /> {t('admin.plugins.reviewed')}
</span>
)
} }
/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */ /** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
@@ -1149,7 +1148,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-[14.5px] font-semibold tracking-[-.006em] text-content">{p.name}</span> <span className="text-[14.5px] font-semibold tracking-[-.006em] text-content">{p.name}</span>
{p.version && <span className="text-[11.5px] text-content-faint font-medium tabular-nums">v{p.version}</span>} {p.version && <span className="text-[11.5px] text-content-faint font-medium tabular-nums">v{p.version}</span>}
{p.reviewed_at && <ReviewedBadge t={t} compact />} {p.reviewed_at && <ReviewedBadge t={t} />}
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />} {p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />} {p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
{/* Registry plugins only — a sideloaded/dev-linked plugin already says something {/* Registry plugins only — a sideloaded/dev-linked plugin already says something
@@ -1401,7 +1400,10 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
}, [item.id]) }, [item.id])
const manifest = detail?.manifest ?? null const manifest = detail?.manifest ?? null
const caps = manifest ? deriveCaps(manifest.permissions, {}, t) : [] const permissions = manifest?.permissions ?? []
const egress = manifest?.egress ?? []
const settings = manifest?.settings ?? []
const caps = manifest ? deriveCaps(permissions, manifest.capabilities ?? {}, t) : []
const repoUrl = `https://github.com/${item.repo}` const repoUrl = `https://github.com/${item.repo}`
const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
@@ -1425,7 +1427,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
<div className="flex-1 min-w-0 pt-8"> <div className="flex-1 min-w-0 pt-8">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-semibold tracking-tight text-content">{item.name}</h3> <h3 className="text-lg font-semibold tracking-tight text-content">{item.name}</h3>
{item.reviewedAt && <ReviewedBadge t={t} compact />} {item.reviewedAt && <ReviewedBadge t={t} />}
</div> </div>
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p> <p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
</div> </div>
@@ -1451,7 +1453,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
{manifest && ( {manifest && (
<div className="mt-5"> <div className="mt-5">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.accessTitle')}</h4> <h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.accessTitle')}</h4>
{caps.filter(c => !c.net).length === 0 && !manifest.permissions.includes('db:own') ? ( {caps.filter(c => !c.net).length === 0 && !permissions.includes('db:own') ? (
<p className="text-xs text-content-faint mt-2">{t('admin.plugins.noAccess')}</p> <p className="text-xs text-content-faint mt-2">{t('admin.plugins.noAccess')}</p>
) : ( ) : (
<div className="mt-2 space-y-1.5"> <div className="mt-2 space-y-1.5">
@@ -1460,7 +1462,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
<c.icon size={15} className="text-accent mt-0.5 shrink-0" /><span>{c.label}</span> <c.icon size={15} className="text-accent mt-0.5 shrink-0" /><span>{c.label}</span>
</div> </div>
))} ))}
{manifest.permissions.includes('db:own') && ( {permissions.includes('db:own') && (
<div className="flex items-start gap-2.5 text-[13px] text-content-secondary py-0.5"> <div className="flex items-start gap-2.5 text-[13px] text-content-secondary py-0.5">
<Database size={15} className="text-accent mt-0.5 shrink-0" /><span>{t('admin.plugins.perm.db:own')}</span> <Database size={15} className="text-accent mt-0.5 shrink-0" /><span>{t('admin.plugins.perm.db:own')}</span>
</div> </div>
@@ -1470,11 +1472,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
</div> </div>
)} )}
{manifest && (manifest.egress.length > 0 || manifest.operatorEgress) && ( {manifest && (egress.length > 0 || manifest.operatorEgress) && (
<div className="mt-5"> <div className="mt-5">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.connectsTitle')}</h4> <h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.connectsTitle')}</h4>
<div className="flex flex-wrap items-center gap-1.5 mt-2"> <div className="flex flex-wrap items-center gap-1.5 mt-2">
{manifest.egress.map(h => ( {egress.map(h => (
<code key={h} className="text-[12px] font-mono text-info bg-info-soft rounded-md px-2 py-1">{h}</code> <code key={h} className="text-[12px] font-mono text-info bg-info-soft rounded-md px-2 py-1">{h}</code>
))} ))}
{/* The hosts above are NOT the whole story for this plugin: it talks to a {/* The hosts above are NOT the whole story for this plugin: it talks to a
@@ -1493,11 +1495,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
</div> </div>
)} )}
{manifest && manifest.settings.length > 0 && ( {manifest && settings.length > 0 && (
<div className="mt-5"> <div className="mt-5">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.setupTitle')}</h4> <h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.setupTitle')}</h4>
<ul className="mt-2 space-y-1.5"> <ul className="mt-2 space-y-1.5">
{manifest.settings.map(s => ( {settings.map(s => (
<li key={s.key} className="flex items-center gap-2 text-xs text-content-muted flex-wrap"> <li key={s.key} className="flex items-center gap-2 text-xs text-content-muted flex-wrap">
<span className="font-medium">{s.label}</span> <span className="font-medium">{s.label}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-content-faint">{t(`admin.plugins.scope.${s.scope}` as never)}</span> <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-content-faint">{t(`admin.plugins.scope.${s.scope}` as never)}</span>
@@ -56,8 +56,8 @@ export default function CategoryManager() {
setEditingId(null) setEditingId(null)
} }
// The Save button carries disabled={… || !form.name.trim()}, so the name is set here.
const handleSave = async () => { const handleSave = async () => {
if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
setIsSaving(true) setIsSaving(true)
try { try {
if (editingId) { if (editingId) {
@@ -48,8 +48,8 @@ function normalizeProvider(value: unknown): MapProvider {
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet' return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
} }
function styleForProvider(provider: MapProvider, style?: string | null): string { /** Only the GL providers keep a style — Leaflet is handled by its callers. */
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE function styleForProvider(provider: GlMapProvider, style?: string | null): string {
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
return normalizeStyleForProvider(provider, style) return normalizeStyleForProvider(provider, style)
} }
@@ -110,7 +110,20 @@ describe('DevNotificationsPanel', () => {
}); });
}); });
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => { it('FE-ADMIN-DEVNOTIF-008: the server error field is what the toast shows', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ error: 'No channel configured' }, { status: 500 }),
),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('No channel configured');
});
it('FE-ADMIN-DEVNOTIF-008b: a failure without an error field falls back to the generic text', async () => {
server.use( server.use(
http.post('/api/admin/dev/test-notification', () => http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 }), HttpResponse.json({ message: 'Server error' }, { status: 500 }),
@@ -120,7 +133,7 @@ describe('DevNotificationsPanel', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>); render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing'); await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!); await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText(/failed|error/i); await screen.findByText('Failed');
}); });
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => { it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { adminApi, tripsApi } from '../../api/client' import { adminApi, tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../utils/apiError'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { import {
@@ -46,8 +47,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
try { try {
await adminApi.sendTestNotification(payload) await adminApi.sendTestNotification(payload)
toast.success(`Sent: ${label}`) toast.success(`Sent: ${label}`)
} catch (err: any) { } catch (err: unknown) {
toast.error(err.message || 'Failed') toast.error(getApiErrorMessage(err, 'Failed'))
} finally { } finally {
setSending(null) setSending(null)
} }
@@ -697,7 +697,7 @@ describe('PackingTemplateManager', () => {
await user.click(categoryButtons('Clothing')[2]); await user.click(categoryButtons('Clothing')[2]);
expect(await screen.findByText('Failed to delete template')).toBeInTheDocument(); expect(await screen.findByText('Failed to delete category')).toBeInTheDocument();
expect(screen.getByText('Clothing')).toBeInTheDocument(); expect(screen.getByText('Clothing')).toBeInTheDocument();
expect(screen.getByText('T-shirt')).toBeInTheDocument(); expect(screen.getByText('T-shirt')).toBeInTheDocument();
}); });
@@ -798,7 +798,7 @@ describe('PackingTemplateManager', () => {
await user.click(itemButtons('T-shirt')[1]); await user.click(itemButtons('T-shirt')[1]);
expect(await screen.findByText('Failed to delete template')).toBeInTheDocument(); expect(await screen.findByText('Failed to delete item')).toBeInTheDocument();
expect(screen.getByText('T-shirt')).toBeInTheDocument(); expect(screen.getByText('T-shirt')).toBeInTheDocument();
}); });
@@ -115,12 +115,13 @@ export default function PackingTemplateManager() {
await adminApi.deleteTemplateCategory(expandedId, catId) await adminApi.deleteTemplateCategory(expandedId, catId)
setCategories(prev => prev.filter(c => c.id !== catId)) setCategories(prev => prev.filter(c => c.id !== catId))
setItems(prev => prev.filter(i => i.category_id !== catId)) setItems(prev => prev.filter(i => i.category_id !== catId))
} catch { toast.error(t('admin.packingTemplates.deleteError')) } } catch { toast.error(t('admin.packingTemplates.deleteCategoryError')) }
} }
// Item CRUD // Item CRUD
const handleAddItem = async (catId: number) => { const handleAddItem = async (catId: number) => {
if (!newItemName.trim() || !expandedId) return // The name is already guaranteed non-empty by the button and the Enter handler.
if (!expandedId) return
try { try {
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() }) const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
setItems(prev => [...prev, data.item]) setItems(prev => [...prev, data.item])
@@ -143,7 +144,7 @@ export default function PackingTemplateManager() {
try { try {
await adminApi.deleteTemplateItem(expandedId, itemId) await adminApi.deleteTemplateItem(expandedId, itemId)
setItems(prev => prev.filter(i => i.id !== itemId)) setItems(prev => prev.filter(i => i.id !== itemId))
} catch { toast.error(t('admin.packingTemplates.deleteError')) } } catch { toast.error(t('admin.packingTemplates.deleteItemError')) }
} }
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none' const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { calcPP, hasCustomMemberSplit } from './BudgetPanel.helpers' import { calcPP, hasCustomMemberSplit, normalizePastedAmount } from './BudgetPanel.helpers'
describe('BudgetPanel.helpers', () => { describe('BudgetPanel.helpers', () => {
describe('hasCustomMemberSplit (#1458)', () => { describe('hasCustomMemberSplit (#1458)', () => {
@@ -25,4 +25,18 @@ describe('BudgetPanel.helpers', () => {
expect(calcPP(100, 0)).toBeNull() expect(calcPP(100, 0)).toBeNull()
expect(calcPP(100, null)).toBeNull() expect(calcPP(100, null)).toBeNull()
}) })
describe('normalizePastedAmount', () => {
it('keeps the last separator as the decimal point', () => {
expect(normalizePastedAmount('1.234,56 €')).toBe('1234.56')
expect(normalizePastedAmount('$1,234.56')).toBe('1234.56')
expect(normalizePastedAmount(' -12,5 ')).toBe('-12.5')
})
it('drops everything that is not part of the number', () => {
expect(normalizePastedAmount('EUR 1 234 567')).toBe('1234567')
expect(normalizePastedAmount('42')).toBe('42')
expect(normalizePastedAmount('abc')).toBe('')
})
})
}) })
@@ -77,3 +77,15 @@ export function splitColorFor(userId: number, order: number) {
export function colorForUserId(userId: number) { export function colorForUserId(userId: number) {
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length] return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
} }
/**
* Normalises a pasted amount to a plain `1234.56` string: drops currency
* symbols and spaces, treats the last comma/dot as the decimal separator and
* removes every thousand separator before it.
*/
export function normalizePastedAmount(raw: string): string {
const text = raw.trim().replace(/[^\d.,-]/g, '')
const decimalPos = Math.max(text.lastIndexOf(','), text.lastIndexOf('.'))
if (decimalPos === -1) return text.replace(/[.,]/g, '')
return text.substring(0, decimalPos).replace(/[.,]/g, '') + '.' + text.substring(decimalPos + 1)
}
@@ -1,6 +1,7 @@
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { normalizePastedAmount } from './BudgetPanel.helpers'
interface AddItemRowProps { interface AddItemRowProps {
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
@@ -33,7 +34,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
</td> </td>
<td style={{ padding: '4px 6px' }}> <td style={{ padding: '4px 6px' }}>
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} <input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }} onPaste={e => { e.preventDefault(); setPrice(normalizePastedAmount(e.clipboardData.getData('text'))) }}
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} /> placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
</td> </td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}> <td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { normalizePastedAmount } from './BudgetPanel.helpers'
interface InlineEditCellProps { interface InlineEditCellProps {
value: string | number | null | undefined value: string | number | null | undefined
@@ -29,21 +30,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => { const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
if (type !== 'number') return if (type !== 'number') return
e.preventDefault() e.preventDefault()
let text = e.clipboardData.getData('text').trim() setEditValue(normalizePastedAmount(e.clipboardData.getData('text')))
// Strip everything except digits, dots, commas, minus
text = text.replace(/[^\d.,-]/g, '')
// Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal
const lastComma = text.lastIndexOf(',')
const lastDot = text.lastIndexOf('.')
const decimalPos = Math.max(lastComma, lastDot)
if (decimalPos > -1) {
const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '')
const decPart = text.substring(decimalPos + 1)
text = intPart + '.' + decPart
} else {
text = text.replace(/[.,]/g, '')
}
setEditValue(text)
} }
if (editing) { if (editing) {
@@ -170,6 +170,35 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(screen.getByText('Unfinished')).toBeInTheDocument() expect(screen.getByText('Unfinished')).toBeInTheDocument()
}) })
it('shows the net hint on a settled expense row and hides it while unfinished', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
// alice fronted the whole 90 for a two-way split, so she is 45 up.
const lent = {
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }),
total_price: 90,
payers: [{ user_id: 1, amount: 90, username: 'alice' }],
members: [{ user_id: 1, username: 'alice', paid: 1 }, { user_id: 2, username: 'bob', paid: 0 }],
}
const unpaid = {
...buildBudgetItem({ trip_id: 1, category: 'lodging', name: 'Hotel' }),
total_price: 90,
payers: [],
members: [{ user_id: 1, username: 'alice', paid: 0 }, { user_id: 2, username: 'bob', paid: 0 }],
}
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [lent, unpaid] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
const dinnerRow = (await screen.findByText('Dinner')).closest('.exp-row') as HTMLElement
expect(within(dinnerRow).getByText(/you lent/)).toHaveTextContent('45')
const hotelRow = (await screen.findByText('Hotel')).closest('.exp-row') as HTMLElement
expect(within(hotelRow).queryByText(/you lent|you borrowed/)).toBeNull()
})
it('sums only unfinished expenses in the Outstanding amount card', async () => { it('sums only unfinished expenses in the Outstanding amount card', async () => {
// Display in the trip's own currency so FX conversion is an identity — keeps the asserted sum deterministic. // Display in the trip's own currency so FX conversion is an identity — keeps the asserted sum deterministic.
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }) seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
+3 -7
View File
@@ -11,6 +11,7 @@ import { budgetApi } from '../../api/client'
import { useExchangeRates } from '../../hooks/useExchangeRates' import { useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile' import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters' import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters'
import { downloadBlob } from '../../utils/fileDownload'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -319,13 +320,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const bom = '' const bom = ''
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim() const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim()
a.download = `costs-${safeName}.csv` downloadBlob(blob, `costs-${safeName}.csv`)
a.click()
URL.revokeObjectURL(url)
} }
// ── small presentational helpers ──────────────────────────────────────── // ── small presentational helpers ────────────────────────────────────────
@@ -763,7 +759,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}> <div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{fmt(baseTotal(e))}</div> <div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && ( {!unfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}> <div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', 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) })} {net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
</div> </div>
@@ -218,12 +218,9 @@ export function ChatMessages(props: any) {
borderRadius: 99, background: 'var(--bg-card)', borderRadius: 99, background: 'var(--bg-card)',
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
}}> }}>
{msg.reactions.map(r => { {msg.reactions.map(r => (
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id)) <ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
return ( ))}
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
)
})}
</div> </div>
</div> </div>
)} )}
@@ -1,4 +1,4 @@
// FE-W4CCS-001 to FE-W4CCS-014 // FE-W4CCS-001 to FE-W4CCS-016
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render' import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import type { ChatReaction } from './CollabChat.types' import type { ChatReaction } from './CollabChat.types'
@@ -155,4 +155,33 @@ describe('WebsiteThumbnail', () => {
await waitFor(() => expect(linkPreview).toHaveBeenCalled()) await waitFor(() => expect(linkPreview).toHaveBeenCalled())
expect(screen.getByRole('link')).toHaveAttribute('title', 'not a url') expect(screen.getByRole('link')).toHaveAttribute('title', 'not a url')
}) })
it('FE-W4CCS-015: caches per trip, because the preview endpoint is trip-scoped', async () => {
linkPreview.mockResolvedValue({ title: 'Trip 4', image: null })
const shared = 'https://example.com/shared'
const first = render(<WebsiteThumbnail url={shared} tripId={4} color="#000" />)
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('title', 'Trip 4'))
first.unmount()
linkPreview.mockResolvedValue({ title: 'Trip 9', image: null })
render(<WebsiteThumbnail url={shared} tripId={9} color="#000" />)
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('title', 'Trip 9'))
expect(linkPreview).toHaveBeenCalledTimes(2)
expect(linkPreview).toHaveBeenLastCalledWith(9, shared)
})
it('FE-W4CCS-016: a broken OG image does not poison the next url', async () => {
linkPreview.mockResolvedValue({ title: 'A', image: 'https://cdn.example/a.png' })
const { container, rerender } = render(<WebsiteThumbnail url="https://a.example/x" tripId={4} color="#000" />)
await waitFor(() => expect(container.querySelector('img')).not.toBeNull())
fireEvent.error(container.querySelector('img')!)
expect(container.querySelector('img')).toBeNull()
linkPreview.mockResolvedValue({ title: 'B', image: 'https://cdn.example/b.png' })
rerender(<WebsiteThumbnail url="https://b.example/y" tripId={4} color="#000" />)
await waitFor(() => expect(container.querySelector('img')).not.toBeNull())
expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/b.png')
})
}) })
@@ -1270,7 +1270,7 @@ describe('CollabNotes', () => {
}); });
}); });
// FE-W5CNT-001 to FE-W5CNT-028 // FE-W5CNT-001 to FE-W5CNT-029
// Fills in the load/error/attachment/category branches of useCollabNotes and the // Fills in the load/error/attachment/category branches of useCollabNotes and the
// view modal that the smoke tests above do not reach. // view modal that the smoke tests above do not reach.
@@ -1802,4 +1802,21 @@ describe('CollabNotes details', () => {
await screen.findByText('Newer note'); await screen.findByText('Newer note');
expect(screen.getByText('Older note')).toBeInTheDocument(); expect(screen.getByText('Older note')).toBeInTheDocument();
}); });
it('FE-W5CNT-029: a failing delete reports an error and keeps the note in the list', async () => {
const user = userEvent.setup();
serveNotes({ notes: [buildNote({ id: 30, title: 'Stubborn note' })] });
server.use(
http.delete('/api/trips/1/collab/notes/30', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Stubborn note');
await user.click(screen.getByTitle('Delete'));
const dialog = (await screen.findByText('Delete note?')).closest('div.trek-modal-enter') as HTMLElement;
await user.click(within(dialog).getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByText('Stubborn note')).toBeInTheDocument();
});
}); });
+3 -2
View File
@@ -271,7 +271,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
{t('collab.notes.title')} {t('collab.notes.title')}
</h3> </h3>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'} {canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}> onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -526,7 +526,8 @@ export default function CollabNotes(props: CollabNotesProps) {
<ConfirmDialog <ConfirmDialog
isOpen={pendingDeleteNoteId !== null} isOpen={pendingDeleteNoteId !== null}
onClose={() => setPendingDeleteNoteId(null)} onClose={() => setPendingDeleteNoteId(null)}
onConfirm={() => { if (pendingDeleteNoteId !== null) handleDeleteNote(pendingDeleteNoteId) }} // Hand the promise back so the dialog absorbs the rethrow of a failed DELETE.
onConfirm={() => (pendingDeleteNoteId !== null ? handleDeleteNote(pendingDeleteNoteId) : undefined)}
title={t('collab.notes.confirmDeleteTitle')} title={t('collab.notes.confirmDeleteTitle')}
message={t('collab.notes.confirmDeleteBody')} message={t('collab.notes.confirmDeleteBody')}
/> />
+5 -5
View File
@@ -39,10 +39,10 @@ interface CollabPanelProps {
} }
const ALL_TABS = [ const ALL_TABS = [
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle }, { id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', icon: MessageCircle },
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote }, { id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', icon: StickyNote },
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 }, { id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', icon: BarChart3 },
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles }, { id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', icon: Sparkles },
] ]
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) { export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
@@ -55,7 +55,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
const tabs = useMemo(() => const tabs = useMemo(() =>
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({ ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
...tab, ...tab,
label: t(tab.labelKey) || tab.fallback, label: t(tab.labelKey),
})), })),
[features, t]) [features, t])
+9 -5
View File
@@ -98,7 +98,7 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
{/* Question */} {/* Question */}
<div> <div>
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div> <div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} /> <input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
</div> </div>
{/* Options */} {/* Options */}
@@ -208,6 +208,8 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
const isClosed = poll.is_closed || isExpired(poll.deadline) const isClosed = poll.is_closed || isExpired(poll.deadline)
const remaining = timeRemaining(poll.deadline) const remaining = timeRemaining(poll.deadline)
const hasVoted = (poll.options || []).some(o => (o.voters || []).some(v => String(v.user_id) === String(currentUser.id))) const hasVoted = (poll.options || []).some(o => (o.voters || []).some(v => String(v.user_id) === String(currentUser.id)))
// Highest vote count across the options; 0 for a poll without options.
const topCount = (poll.options || []).reduce((max, o) => Math.max(max, o.voters?.length || 0), 0)
return ( return (
<div style={{ <div style={{
@@ -271,10 +273,12 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
const count = opt.voters?.length || 0 const count = opt.voters?.length || 0
const pct = total > 0 ? Math.round((count / total) * 100) : 0 const pct = total > 0 ? Math.round((count / total) * 100) : 0
const myVote = (opt.voters || []).some(v => String(v.user_id) === String(currentUser.id)) const myVote = (opt.voters || []).some(v => String(v.user_id) === String(currentUser.id))
const isWinner = isClosed && count === Math.max(...(poll.options || []).map(o => o.voters?.length || 0)) && count > 0 const isWinner = isClosed && count > 0 && count === topCount
return ( return (
<button key={idx} onClick={() => !isClosed && onVote(poll.id, idx)} // React dispatches no mouse events on a disabled control, so the
// handlers below need no isClosed guard of their own.
<button key={idx} onClick={() => onVote(poll.id, idx)}
disabled={isClosed} disabled={isClosed}
style={{ style={{
position: 'relative', display: 'flex', alignItems: 'center', gap: 8, position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
@@ -282,7 +286,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%', background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
overflow: 'hidden', transition: 'transform 0.1s', overflow: 'hidden', transition: 'transform 0.1s',
}} }}
onMouseEnter={e => { if (!isClosed) e.currentTarget.style.transform = 'scale(1.01)' }} onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.01)' }}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'} onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
> >
{/* Progress bar background */} {/* Progress bar background */}
@@ -474,7 +478,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
<> <>
{activePolls.length > 0 && ( {activePolls.length > 0 && (
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}> <div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
{t('collab.polls.closedSection') || 'Closed'} {t('collab.polls.closedSection')}
</div> </div>
)} )}
{closedPolls.map(poll => ( {closedPolls.map(poll => (
@@ -261,6 +261,14 @@ describe('AddPlaceToCollectionModal', () => {
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('2.3522') expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('2.3522')
}) })
it('FE-COMP-ADDPLACECOL-015b: the pair is split the same way when pasted into longitude', () => {
setup()
const lng = screen.getByPlaceholderText('Longitude (e.g. 2.3522)')
fireEvent.paste(lng, { clipboardData: { getData: () => '48.8566, 2.3522' } })
expect(screen.getByPlaceholderText('Latitude (e.g. 48.8566)')).toHaveValue('48.8566')
expect(lng).toHaveValue('2.3522')
})
it('FE-COMP-ADDPLACECOL-016: a paste that is not a coordinate pair is left to the input', () => { it('FE-COMP-ADDPLACECOL-016: a paste that is not a coordinate pair is left to the input', () => {
setup() setup()
const lat = screen.getByPlaceholderText('Latitude (e.g. 48.8566)') const lat = screen.getByPlaceholderText('Latitude (e.g. 48.8566)')
@@ -192,7 +192,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
<input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={coordInputClass} /> <input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={coordInputClass} />
<div className="grid grid-cols-2 gap-2 mt-2"> <div className="grid grid-cols-2 gap-2 mt-2">
<NumericInput mode="signed" value={lat} onValueChange={setLat} onPaste={coordPaste} placeholder={t('places.formLat')} className={coordInputClass} /> <NumericInput mode="signed" value={lat} onValueChange={setLat} onPaste={coordPaste} placeholder={t('places.formLat')} className={coordInputClass} />
<NumericInput mode="signed" value={lng} onValueChange={setLng} placeholder={t('places.formLng')} className={coordInputClass} /> <NumericInput mode="signed" value={lng} onValueChange={setLng} onPaste={coordPaste} placeholder={t('places.formLng')} className={coordInputClass} />
</div> </div>
</div> </div>
@@ -31,6 +31,28 @@ function distanceTo(p: CollectionPlace, center: { lat: number; lng: number }): n
interface Opt { key: string | number; label: string; icon?: React.ReactNode; count?: number } interface Opt { key: string | number; label: string; icon?: React.ReactNode; count?: number }
/** Detail requests fired at once while building the union — keeps a user with
* many lists from opening the modal with a burst of parallel requests. */
const DETAIL_BATCH = 4
/** Union of every list's places, in list order, without duplicates. */
async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
const merged: CollectionPlace[] = []
const seen = new Set<number>()
for (let i = 0; i < ids.length; i += DETAIL_BATCH) {
const batch = await Promise.all(ids.slice(i, i + DETAIL_BATCH).map(id => collectionsApi.get(id).catch(() => null)))
for (const d of batch) {
if (!d) continue
for (const p of d.places) {
if (seen.has(p.id)) continue
seen.add(p.id)
merged.push(p)
}
}
}
return merged
}
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */ /** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
function FilterDropdown({ current, options, onSelect, lead }: { function FilterDropdown({ current, options, onSelect, lead }: {
current: string | number current: string | number
@@ -93,13 +115,8 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
setLoading(true) setLoading(true)
collectionsApi.list() collectionsApi.list()
.then(async (res) => { .then(async (res) => {
const detail = await Promise.all(res.collections.map(c => collectionsApi.get(c.id).catch(() => null))) const merged = await loadSavedPlaces(res.collections.map(c => c.id))
if (cancelled) return if (cancelled) return
const merged: CollectionPlace[] = []
for (const d of detail) {
if (!d) continue
for (const p of d.places) merged.push(p)
}
setLists(res.collections.map(c => ({ id: c.id, name: c.name, color: c.color ?? null }))) setLists(res.collections.map(c => ({ id: c.id, name: c.name, color: c.color ?? null })))
setPlaces(merged) setPlaces(merged)
}) })
@@ -233,7 +233,8 @@ describe('CollectionPlaceDetail', () => {
const input = document.querySelector('input[type="file"]') as HTMLInputElement; const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } }); fireEvent.change(input, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } });
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Upload rejected', 'error', undefined)); // A rejection without a server payload falls back to the localized message.
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not upload image', 'error', undefined));
// The spinner is gone again, so the control accepts a retry. // The spinner is gone again, so the control accepts a retry.
await waitFor(() => expect(document.querySelector('.animate-spin')).toBeNull()); await waitFor(() => expect(document.querySelector('.animate-spin')).toBeNull());
}); });
@@ -15,7 +15,7 @@ import { useToast } from '../shared/Toast'
import { Tooltip } from '../shared/Tooltip' import { Tooltip } from '../shared/Tooltip'
import PlaceRating from '../shared/StarRating' import PlaceRating from '../shared/StarRating'
import { normalizeImageFile } from '../../utils/convertHeic' import { normalizeImageFile } from '../../utils/convertHeic'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../utils/apiError'
function linkHost(url: string): string { function linkHost(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url } try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
@@ -322,7 +322,8 @@ describe('ListEditorModal', () => {
const { props } = setup() const { props } = setup()
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } }) fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' })) fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('boom', 'error', undefined)) // A plain failure has no server-provided message, so the translated fallback shows.
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined))
expect(props.onClose).not.toHaveBeenCalled() expect(props.onClose).not.toHaveBeenCalled()
// The save button releases again so the user can retry. // The save button releases again so the user can retry.
await waitFor(() => expect(screen.getByRole('button', { name: 'Create' })).not.toBeDisabled()) await waitFor(() => expect(screen.getByRole('button', { name: 'Create' })).not.toBeDisabled())
@@ -4,7 +4,7 @@ import Modal from '../shared/Modal'
import { useCollectionStore } from '../../store/collectionStore' import { useCollectionStore } from '../../store/collectionStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { tripsApi } from '../../api/client' import { tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../utils/apiError'
import { normalizeLinkUrl } from '../../pages/collections/collectionsModel' import { normalizeLinkUrl } from '../../pages/collections/collectionsModel'
import type { TranslationFn } from '../../types' import type { TranslationFn } from '../../types'
import type { Collection, CollectionLink } from '@trek/shared' import type { Collection, CollectionLink } from '@trek/shared'
@@ -219,7 +219,7 @@ describe('SaveToCollectionModal', () => {
expect(useSaveToCollectionStore.getState().version).toBe(0); expect(useSaveToCollectionStore.getState().version).toBe(0);
}); });
it('FE-COMP-SAVETOCOL-013: a second click on the busy row is ignored while the save runs', async () => { it('FE-COMP-SAVETOCOL-013: every row locks while a save runs', async () => {
let resolve!: (v: CollectionSaveResult) => void; let resolve!: (v: CollectionSaveResult) => void;
const save = vi.spyOn(collectionsApi, 'savePlace').mockReturnValue(new Promise(r => { resolve = r; })); const save = vi.spyOn(collectionsApi, 'savePlace').mockReturnValue(new Promise(r => { resolve = r; }));
openFor(); openFor();
@@ -228,7 +228,9 @@ describe('SaveToCollectionModal', () => {
const wishlist = await screen.findByRole('button', { name: /Wishlist/ }); const wishlist = await screen.findByRole('button', { name: /Wishlist/ });
fireEvent.click(wishlist); fireEvent.click(wishlist);
expect(wishlist).toBeDisabled(); expect(wishlist).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: /Favorites/ })); const favorites = screen.getByRole('button', { name: /Favorites/ });
expect(favorites).toBeDisabled();
fireEvent.click(favorites);
expect(save).toHaveBeenCalledTimes(1); expect(save).toHaveBeenCalledTimes(1);
resolve({}); resolve({});
@@ -169,7 +169,7 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
key={list.id} key={list.id}
type="button" type="button"
onClick={() => handleToggle(list)} onClick={() => handleToggle(list)}
disabled={busy} disabled={busyId != null}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`} className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
> >
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: list.color || 'var(--accent)' }} /> <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: list.color || 'var(--accent)' }} />
@@ -98,7 +98,7 @@ describe('SaveTripPlacesToListModal', () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse(many)); vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse(many));
renderModal(); renderModal();
const search = await screen.findByPlaceholderText('Search trips'); const search = await screen.findByPlaceholderText('Search lists');
fireEvent.change(search, { target: { value: 'list 4' } }); fireEvent.change(search, { target: { value: 'list 4' } });
expect(screen.getByText('List 4')).toBeInTheDocument(); expect(screen.getByText('List 4')).toBeInTheDocument();
expect(screen.queryByText('List 5')).not.toBeInTheDocument(); expect(screen.queryByText('List 5')).not.toBeInTheDocument();
@@ -110,7 +110,7 @@ describe('SaveTripPlacesToListModal', () => {
it('FE-COMP-SAVETRIPPL-007: five lists or fewer need no search box', async () => { it('FE-COMP-SAVETRIPPL-007: five lists or fewer need no search box', async () => {
renderModal(); renderModal();
await screen.findByText('Favorites'); await screen.findByText('Favorites');
expect(screen.queryByPlaceholderText('Search trips')).not.toBeInTheDocument(); expect(screen.queryByPlaceholderText('Search lists')).not.toBeInTheDocument();
}); });
it('FE-COMP-SAVETRIPPL-008: picking a list copies every selected place, reports it and closes', async () => { it('FE-COMP-SAVETRIPPL-008: picking a list copies every selected place, reports it and closes', async () => {
@@ -77,7 +77,7 @@ export default function SaveTripPlacesToListModal({ isOpen, tripId, placeIds, on
autoFocus autoFocus
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')} placeholder={t('collections.searchLists')}
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent" className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/> />
</div> </div>
@@ -114,6 +114,7 @@ export default function ShareCollectionModal({
} }
const handleSetRole = async (userId: number, role: CollectionRole) => { const handleSetRole = async (userId: number, role: CollectionRole) => {
if (settingRoleId != null) return
setSettingRoleId(userId) setSettingRoleId(userId)
try { try {
await setMemberRole(collectionId, userId, role) await setMemberRole(collectionId, userId, role)
@@ -1,4 +1,4 @@
// FE-W4LBX-001 to FE-W4LBX-017 // FE-W4LBX-001 to FE-W4LBX-019
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { TripFile } from '../../types' import type { TripFile } from '../../types'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render' import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
@@ -208,4 +208,41 @@ describe('ImageLightbox', () => {
expect(onClose).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled()
}) })
it('FE-W4LBX-018: Escape reaches the current onClose after a re-render', () => {
const first = vi.fn()
const second = vi.fn()
const { rerender } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={first} />)
rerender(<ImageLightbox files={IMAGES} initialIndex={0} onClose={second} />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(second).toHaveBeenCalledOnce()
expect(first).not.toHaveBeenCalled()
})
it('FE-W4LBX-019: the strip mints thumbnail tokens only for thumbs in view', async () => {
// Every thumbnail costs its own one-shot token, so an off-screen thumb must
// stay quiet. The default observer stub never intersects.
const { unmount } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download'))
expect(getAuthUrl).toHaveBeenCalledTimes(1)
unmount()
getAuthUrl.mockClear()
const original = globalThis.IntersectionObserver
globalThis.IntersectionObserver = class {
constructor(private cb: IntersectionObserverCallback) {}
observe() { this.cb([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver) }
disconnect() {}
unobserve() {}
takeRecords() { return [] }
} as unknown as typeof IntersectionObserver
try {
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/c.jpg', 'download'))
} finally {
globalThis.IntersectionObserver = original
}
})
}) })
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { ExternalLink, Download, X, ChevronLeft, ChevronRight, Play } from 'lucide-react' import { ExternalLink, Download, X, ChevronLeft, ChevronRight, Play } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import type { TripFile } from '../../types' import type { TripFile } from '../../types'
@@ -41,7 +41,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
} }
window.addEventListener('keydown', handler) window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler)
}, []) }, [onClose])
if (!file) return null if (!file) return null
@@ -128,11 +128,30 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) { function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
const fileIsVideo = isVideo(file.mime_type) const fileIsVideo = isVideo(file.mime_type)
const [src, setSrc] = useState('') const [src, setSrc] = useState('')
const [visible, setVisible] = useState(false)
const ref = useRef<HTMLButtonElement>(null)
// Each thumbnail costs its own one-shot download token, so the strip only
// mints them for the thumbs that actually scroll into view.
useEffect(() => {
const el = ref.current
if (!el || typeof IntersectionObserver !== 'function') { setVisible(true); return }
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '200px' })
io.observe(el)
return () => io.disconnect()
}, [])
// Videos have no stored thumbnail and can't render as an <img>; show a play // Videos have no stored thumbnail and can't render as an <img>; show a play
// placeholder and don't mint a download token for them (#823). // placeholder and don't mint a download token for them (#823).
useEffect(() => { if (!fileIsVideo) getAuthUrl(file.url, 'download').then(setSrc) }, [file.url, fileIsVideo]) useEffect(() => {
if (!visible || fileIsVideo) return
let current = true
getAuthUrl(file.url, 'download').then(u => { if (current) setSrc(u) })
return () => { current = false }
}, [file.url, fileIsVideo, visible])
return ( return (
<button onClick={onClick} style={{ <button ref={ref} onClick={onClick} style={{
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent', width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s', opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)',
@@ -54,6 +54,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
setShowTrash(v => !v) setShowTrash(v => !v)
}, [showTrash, loadTrash]) }, [showTrash, loadTrash])
// onUpdate doubles as the "files changed" signal towards the parent; the arguments carry no payload.
const refreshFiles = useCallback(async () => { const refreshFiles = useCallback(async () => {
if (onUpdate) onUpdate(0, {} as any) if (onUpdate) onUpdate(0, {} as any)
}, [onUpdate]) }, [onUpdate])
@@ -208,13 +208,20 @@ describe('NoFearShow', () => {
expect(signal.aborted).toBe(true) expect(signal.aborted).toBe(true)
}) })
it('FE-NOFEAR-SHOW-005: re-fits the canvas on window resize', () => { it('FE-NOFEAR-SHOW-005: coalesces a resize storm into a single re-fit', () => {
render(<NoFearShow onClose={vi.fn()} />) render(<NoFearShow onClose={vi.fn()} />)
expect(scene().layout).toHaveBeenCalledTimes(1) expect(scene().layout).toHaveBeenCalledTimes(1)
act(() => { window.dispatchEvent(new Event('resize')) }) act(() => {
for (let i = 0; i < 20; i++) window.dispatchEvent(new Event('resize'))
})
// layout() rebakes every static layer, so nothing happens while the drag runs
expect(scene().layout).toHaveBeenCalledTimes(1)
act(() => { vi.advanceTimersByTime(150) })
expect(scene().layout).toHaveBeenCalledTimes(2) expect(scene().layout).toHaveBeenCalledTimes(2)
expect(scene().layout).toHaveBeenLastCalledWith(800, 600)
}) })
it('FE-NOFEAR-SHOW-006: shows no line before the first cue', () => { it('FE-NOFEAR-SHOW-006: shows no line before the first cue', () => {
@@ -248,10 +255,9 @@ describe('NoFearShow', () => {
expect(ghost).not.toBeNull() expect(ghost).not.toBeNull()
expect(ghost?.querySelectorAll('.fz-char-decay')).toHaveLength(LINES.afraid.length) expect(ghost?.querySelectorAll('.fz-char-decay')).toHaveLength(LINES.afraid.length)
// The 1.4s retirement timer captures `lastCue` by reference, and the loop has // Once its letters have decayed the ghost retires itself.
// already advanced it — so the faded-out ghost node stays in the DOM.
act(() => { vi.advanceTimersByTime(1400) }) act(() => { vi.advanceTimersByTime(1400) })
expect(document.querySelector('.fz-line-ghost')).not.toBeNull() expect(document.querySelector('.fz-line-ghost')).toBeNull()
frame(12.1) frame(12.1)
const ghostText = document.querySelector('.fz-line-ghost')?.textContent ?? '' const ghostText = document.querySelector('.fz-line-ghost')?.textContent ?? ''
@@ -572,6 +578,13 @@ describe('NoFearShow', () => {
frame(76) frame(76)
expect(stubs.assembly).toHaveLength(1) expect(stubs.assembly).toHaveLength(1)
expect(screen.getByRole('heading', { level: 1 })).not.toHaveClass('fz-word-hidden') expect(screen.getByRole('heading', { level: 1 })).not.toHaveClass('fz-word-hidden')
// Targets are absolute screen coordinates, so a resize invalidates them and
// the next frame re-samples the moved title.
act(() => { window.dispatchEvent(new Event('resize')) })
act(() => { vi.advanceTimersByTime(150) })
frame(77)
expect(stubs.assembly).toHaveLength(2)
}) })
it('FE-NOFEAR-SHOW-031: tears down chrome, listeners and audio on unmount', () => { it('FE-NOFEAR-SHOW-031: tears down chrome, listeners and audio on unmount', () => {
+17 -5
View File
@@ -114,10 +114,20 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
cv.height = Math.round(cv.clientHeight * dpr) cv.height = Math.round(cv.clientHeight * dpr)
cv.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0) cv.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0)
scene.layout(cv.clientWidth, cv.clientHeight) scene.layout(cv.clientWidth, cv.clientHeight)
// The finale samples the title rect in screen coordinates, so it has to be
// re-sampled against the new geometry.
assemblyRef.current = null
} }
fit() fit()
if (cv) void scene.load(cv.clientWidth, cv.clientHeight, aborter.signal) if (cv) void scene.load(cv.clientWidth, cv.clientHeight, aborter.signal)
window.addEventListener('resize', fit) // layout() rebakes every static layer, which is far too heavy for the event
// storm a window drag produces — coalesce to the end of the gesture.
let refitTimer = 0
const onResize = () => {
window.clearTimeout(refitTimer)
refitTimer = window.setTimeout(fit, 150)
}
window.addEventListener('resize', onResize)
// The traveler's own places, gathered quietly during the fear act. Fail-soft: // The traveler's own places, gathered quietly during the fear act. Fail-soft:
// without them the show falls back to its generic lines. // without them the show falls back to its generic lines.
void (async () => { void (async () => {
@@ -163,7 +173,8 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
if (reducedMotion) skipToEnd() if (reducedMotion) skipToEnd()
return () => { return () => {
document.body.classList.remove('fz-show-open') document.body.classList.remove('fz-show-open')
window.removeEventListener('resize', fit) window.clearTimeout(refitTimer)
window.removeEventListener('resize', onResize)
document.removeEventListener('visibilitychange', onVisibility) document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('pointerdown', onPointer) window.removeEventListener('pointerdown', onPointer)
aborter.abort() aborter.abort()
@@ -191,10 +202,11 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
if (idx !== lastCue && idx >= 0) { if (idx !== lastCue && idx >= 0) {
// A replaced fear-act line decays letter by letter instead of vanishing. // A replaced fear-act line decays letter by letter instead of vanishing.
if (lastCue >= 0 && lastCue <= 3) { if (lastCue >= 0 && lastCue <= 3) {
const prevKey = CUES[lastCue].line const ghostId = lastCue // pin it — lastCue advances on the next line
const prevKey = CUES[ghostId].line
if (prevKey) { if (prevKey) {
setGhost({ text: copy.lines[prevKey], id: lastCue }) setGhost({ text: copy.lines[prevKey], id: ghostId })
window.setTimeout(() => setGhost(g => (g?.id === lastCue ? null : g)), 1400) window.setTimeout(() => setGhost(g => (g?.id === ghostId ? null : g)), 1400)
} }
} }
lastCue = idx lastCue = idx
@@ -213,6 +213,23 @@ describe('TextAssembly.init', () => {
expect(main.fills).toHaveLength(0) expect(main.fills).toHaveLength(0)
}) })
it('FE-NOFEAR-ASM-019: drops the previous targets when a re-init cannot raster', () => {
alphaAt = () => 255
cycleRandom([0.1])
const a = new TextAssembly()
a.init('X', 'bold 64px Inter', BOX, 1000, 600)
expect(a.isReady()).toBe(true)
contextAvailable = false
a.init('X', 'bold 64px Inter', BOX, 1000, 600)
expect(a.isReady()).toBe(false)
const main = makeCtx()
a.draw(asCtx(main), 1, 0, 0)
expect(main.fills).toHaveLength(0)
})
it('FE-NOFEAR-ASM-009: keeps a degenerate box at one raster pixel', () => { it('FE-NOFEAR-ASM-009: keeps a degenerate box at one raster pixel', () => {
alphaAt = () => 255 alphaAt = () => 255
cycleRandom([0.1, 0.1, 0.1, 0.1, 0.1, 0]) cycleRandom([0.1, 0.1, 0.1, 0.1, 0.1, 0])
@@ -32,6 +32,10 @@ export class TextAssembly {
off.width = Math.max(Math.round(box.width * scale), 1) off.width = Math.max(Math.round(box.width * scale), 1)
off.height = Math.max(Math.round(box.height * scale), 1) off.height = Math.max(Math.round(box.height * scale), 1)
const c = off.getContext('2d') const c = off.getContext('2d')
// A re-init that cannot raster must go quiet rather than keep the previous
// run's particles on screen at coordinates that no longer hold.
this.ready = false
this.particles = []
if (!c) return if (!c) return
c.font = font.replace(/(\d+(?:\.\d+)?)px/, (_, n) => `${Number(n) * scale}px`) c.font = font.replace(/(\d+(?:\.\d+)?)px/, (_, n) => `${Number(n) * scale}px`)
c.textAlign = 'center' c.textAlign = 'center'
@@ -40,7 +44,6 @@ export class TextAssembly {
c.fillText(text, off.width / 2, off.height / 2) c.fillText(text, off.width / 2, off.height / 2)
const img = c.getImageData(0, 0, off.width, off.height).data const img = c.getImageData(0, 0, off.width, off.height).data
const step = 3 // sample grid in raster px — ~800-1400 particles for a title const step = 3 // sample grid in raster px — ~800-1400 particles for a title
this.particles = []
for (let y = 0; y < off.height; y += step) { for (let y = 0; y < off.height; y += step) {
for (let x = 0; x < off.width; x += step) { for (let x = 0; x < off.width; x += step) {
if (img[(y * off.width + x) * 4 + 3] > 128) { if (img[(y * off.width + x) * 4 + 3] > 128) {
@@ -144,7 +144,7 @@ export class NoFearScene {
if (r.length < step * 4) continue // rings too small to survive decimation are visual noise if (r.length < step * 4) continue // rings too small to survive decimation are visual noise
const thin: Ring = [] const thin: Ring = []
for (let i = 0; i < r.length; i += step) thin.push(r[i]) for (let i = 0; i < r.length; i += step) thin.push(r[i])
if (thin.length >= 4) rings.push(thin) rings.push(thin)
} }
this.rings = rings this.rings = rings
if (signal?.aborted) return if (signal?.aborted) return
@@ -66,11 +66,11 @@ describe('JournalBody', () => {
expect(code!.getAttribute('style')).toContain('rgba(0, 0, 0, 0.06)'); expect(code!.getAttribute('style')).toContain('rgba(0, 0, 0, 0.06)');
}); });
it('FE-COMP-JOURNALBODY-010: renders fenced code blocks inside a pre element', () => { it('FE-COMP-JOURNALBODY-010: renders fenced code blocks inside a single pre element', () => {
const { container } = render(<JournalBody text={'```js\nconst a = 1\n```'} />); const { container } = render(<JournalBody text={'```js\nconst a = 1\n```'} />);
const pre = container.querySelector('pre'); const pres = container.querySelectorAll('pre');
expect(pre).toBeInTheDocument(); expect(pres).toHaveLength(1);
expect(pre!.querySelector('code')!.textContent).toContain('const a = 1'); expect(pres[0].querySelector('code')!.textContent).toContain('const a = 1');
}); });
it('FE-COMP-JOURNALBODY-011: renders h1 and h3 as plain paragraphs', () => { it('FE-COMP-JOURNALBODY-011: renders h1 and h3 as plain paragraphs', () => {
@@ -93,7 +93,9 @@ describe('JournalBody', () => {
const { container } = render(<JournalBody text={'`x`\n\n```js\ny\n```'} dark />); const { container } = render(<JournalBody text={'`x`\n\n```js\ny\n```'} dark />);
const [inline] = Array.from(container.querySelectorAll('code')); const [inline] = Array.from(container.querySelectorAll('code'));
expect(inline.getAttribute('style')).toContain('rgba(255, 255, 255, 0.08)'); expect(inline.getAttribute('style')).toContain('rgba(255, 255, 255, 0.08)');
// react-markdown supplies its own outer <pre>; the styled one is nested inside. // the block styling sits on react-markdown's own <pre>, there is no second one
expect(container.querySelectorAll('pre')[1].getAttribute('style')).toContain('rgba(255, 255, 255, 0.05)'); const pres = container.querySelectorAll('pre');
expect(pres).toHaveLength(1);
expect(pres[0].getAttribute('style')).toContain('rgba(255, 255, 255, 0.05)');
}); });
}); });
+10 -11
View File
@@ -41,19 +41,18 @@ export default function JournalBody({ text, dark }: Props) {
strong: ({ children }) => <strong style={{ fontWeight: 600 }}>{children}</strong>, strong: ({ children }) => <strong style={{ fontWeight: 600 }}>{children}</strong>,
em: ({ children }) => <em>{children}</em>, em: ({ children }) => <em>{children}</em>,
hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--journal-border)', margin: '20px 0' }} />, hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--journal-border)', margin: '20px 0' }} />,
// react-markdown already wraps fenced code in a <pre>, so the block
// styling belongs here — the code renderer only handles inline code.
pre: ({ children }) => (
<pre style={{
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, padding: 14, overflowX: 'auto',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'monospace', margin: '12px 0',
}}>{children}</pre>
),
code: ({ children, className }) => { code: ({ children, className }) => {
const isBlock = className?.includes('language-') const isBlock = className?.includes('language-')
if (isBlock) { if (isBlock) return <code>{children}</code>
return (
<pre style={{
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, padding: 14, overflowX: 'auto',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'monospace', margin: '12px 0',
}}>
<code>{children}</code>
</pre>
)
}
return ( return (
<code style={{ <code style={{
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
@@ -431,6 +431,27 @@ describe('EntryEditor', () => {
expect(await screen.findByText('No connected photo providers are available.')).toBeInTheDocument() expect(await screen.findByText('No connected photo providers are available.')).toBeInTheDocument()
}) })
it('FE-JRN-EDITOR-019b: leaving the external tab while the providers load does not strand the picker', async () => {
server.use(
http.get('/api/addons', async () => {
await delay(120)
return HttpResponse.json({
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true }],
})
}),
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [], hasMore: false })),
)
const user = userEvent.setup()
mountEditor(buildEntry({ id: 10 }))
await user.click(screen.getByRole('button', { name: 'External photos' }))
await user.click(screen.getByRole('button', { name: /Upload photos/ }))
await user.click(screen.getByRole('button', { name: 'External photos' }))
expect(await screen.findByTestId('journey-external-provider-immich')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-020: queues photos picked from a connected provider and clears them again', async () => { it('FE-JRN-EDITOR-020: queues photos picked from a connected provider and clears them again', async () => {
useConnectedImmich() useConnectedImmich()
const user = userEvent.setup() const user = userEvent.setup()
@@ -86,8 +86,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
useEffect(() => { useEffect(() => {
if (photoTab !== 'external' || availableProviders.length > 0 || providersLoading) return if (photoTab !== 'external' || availableProviders.length > 0 || providersLoading) return
let cancelled = false
setProvidersLoading(true) setProvidersLoading(true)
// The discovery is not tied to the open tab, so the result is applied even if
// the user left the tab meanwhile — dropping it would leave providersLoading
// stuck and block every later run of this effect.
;(async () => { ;(async () => {
try { try {
const addonsData = await addonsApi.enabled() const addonsData = await addonsApi.enabled()
@@ -99,14 +101,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
if (response.ok && (await response.json()).connected) connected.push({ id: provider.id, name: provider.name }) if (response.ok && (await response.json()).connected) connected.push({ id: provider.id, name: provider.name })
} catch {} } catch {}
} }
if (!cancelled) { setAvailableProviders(connected)
setAvailableProviders(connected) if (connected.length > 0) setExternalProvider(current => current || connected[0].id)
if (connected.length > 0) setExternalProvider(current => current || connected[0].id)
}
} catch {} } catch {}
if (!cancelled) setProvidersLoading(false) setProvidersLoading(false)
})() })()
return () => { cancelled = true }
}, [photoTab, availableProviders.length]) }, [photoTab, availableProviders.length])
const activeExternalProvider = externalProvider || availableProviders[0]?.id || null const activeExternalProvider = externalProvider || availableProviders[0]?.id || null
@@ -25,10 +25,6 @@ export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fu
}) })
const dates = [...byDate.keys()].sort() const dates = [...byDate.keys()].sort()
// find first and last entry indices
const firstId = mapEntries[0]?.id
const lastId = mapEntries[mapEntries.length - 1]?.id
const mapItems = useMemo(() => mapEntries.map(e => ({ const mapItems = useMemo(() => mapEntries.map(e => ({
id: String(e.id), id: String(e.id),
lat: e.location_lat!, lat: e.location_lat!,
@@ -85,8 +81,6 @@ export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fu
{/* Location items */} {/* Location items */}
{items.map(({ entry: e, globalIdx }, itemIdx) => { {items.map(({ entry: e, globalIdx }, itemIdx) => {
const isActive = activeLocationId === String(e.id) const isActive = activeLocationId === String(e.id)
const isFirst = e.id === firstId
const isLast = e.id === lastId
const showConnector = itemIdx < items.length - 1 const showConnector = itemIdx < items.length - 1
return ( return (
@@ -323,6 +323,19 @@ describe('JourneySettingsDialog', () => {
expect(await screen.findByRole('heading', { name: 'Link Trip' })).toBeInTheDocument() expect(await screen.findByRole('heading', { name: 'Link Trip' })).toBeInTheDocument()
}) })
it('FE-JRN-SETTINGS-019b: closing the link-trip dialog leaves the settings dialog alone', async () => {
const user = userEvent.setup()
const { onClose } = mountDialog(buildJourney({ trips: [] }))
await user.click(screen.getByRole('button', { name: 'Add Trip' }))
const linkHeading = await screen.findByRole('heading', { name: 'Link Trip' })
await user.click(linkHeading.parentElement!.querySelector('button')!)
expect(screen.queryByRole('heading', { name: 'Link Trip' })).not.toBeInTheDocument()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByRole('heading', { name: 'Journey Settings' })).toBeInTheDocument()
})
it('FE-JRN-SETTINGS-020: closes straight away when nothing was edited', async () => { it('FE-JRN-SETTINGS-020: closes straight away when nothing was edited', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const { onClose } = mountDialog() const { onClose } = mountDialog()
@@ -275,14 +275,17 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
danger danger
/> />
{/* Add Trip */} {/* Add Trip — sits inside the backdrop, so its clicks have to be kept
from bubbling into the close/discard handler underneath */}
{showAddTrip && ( {showAddTrip && (
<AddTripDialog <div onClick={e => e.stopPropagation()}>
journeyId={journey.id} <AddTripDialog
existingTripIds={journey.trips.map((t: any) => t.trip_id)} journeyId={journey.id}
onClose={() => setShowAddTrip(false)} existingTripIds={journey.trips.map((t: any) => t.trip_id)}
onAdded={() => { setShowAddTrip(false); onSaved() }} onClose={() => setShowAddTrip(false)}
/> onAdded={() => { setShowAddTrip(false); onSaved() }}
/>
</div>
)} )}
<ConfirmDialog <ConfirmDialog
@@ -154,10 +154,6 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
useEffect(() => { useEffect(() => {
if (!containerRef.current) return if (!containerRef.current) return
if (mapRef.current) {
mapRef.current.remove()
mapRef.current = null
}
markersRef.current.clear() markersRef.current.clear()
const map = L.map(containerRef.current, { const map = L.map(containerRef.current, {
@@ -549,6 +549,20 @@ describe('JourneyMapGL', () => {
expect(addTerrainAndSky).not.toHaveBeenCalled() expect(addTerrainAndSky).not.toHaveBeenCalled()
}) })
it('FE-COMP-JMAPGL-031b: switching the ui language repins the labels without rebuilding the map', () => {
withToken({ language: 'gr' })
vi.mocked(isStandardFamily).mockReturnValue(true)
render(<JourneyMapGL checkins={[]} entries={entries} />)
const builds = vi.mocked(mapboxgl.Map).mock.calls.length
gl.map.setConfigProperty.mockClear()
act(() => { withToken({ language: 'fr' }) })
expect(gl.map.setConfigProperty).toHaveBeenCalledWith('basemap', 'language', 'fr')
expect(vi.mocked(mapboxgl.Map).mock.calls).toHaveLength(builds)
})
it('FE-COMP-JMAPGL-032: sdk failures while configuring the basemap do not break the load', () => { it('FE-COMP-JMAPGL-032: sdk failures while configuring the basemap do not break the load', () => {
withToken() withToken()
vi.mocked(isStandardFamily).mockReturnValue(true) vi.mocked(isStandardFamily).mockReturnValue(true)
+11 -1
View File
@@ -233,6 +233,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
onMarkerClickRef.current = onMarkerClick onMarkerClickRef.current = onMarkerClick
const darkRef = useRef(dark) const darkRef = useRef(dark)
darkRef.current = dark darkRef.current = dark
const mapLangRef = useRef(mapLang)
mapLangRef.current = mapLang
const showPopup = useCallback((id: string) => { const showPopup = useCallback((id: string) => {
const item = itemsRef.current.find(i => i.id === id) const item = itemsRef.current.find(i => i.id === id)
@@ -382,7 +384,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
// Pin the basemap label language to the UI language so labels don't fall back to the // 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). // browser/OS locale and stack multiple scripts per place (#1299).
if (!isMapLibre && isStandardFamily(glStyle)) { if (!isMapLibre && isStandardFamily(glStyle)) {
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support it */ } try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLangRef.current)) } catch { /* style/SDK may not support it */ }
} }
// route trail — dashed line connecting entries in time order // route trail — dashed line connecting entries in time order
@@ -451,6 +453,14 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
} }
}, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom]) }, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// Switching the UI language has to repin the basemap labels without tearing
// the map down. The load handler covers the initial run.
useEffect(() => {
const map = mapRef.current
if (!map || isMapLibre || !isStandardFamily(glStyle)) return
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support it */ }
}, [mapLang, isMapLibre, glStyle])
// external activeMarkerId → highlight + flyTo // external activeMarkerId → highlight + flyTo
useEffect(() => { useEffect(() => {
if (!activeMarkerId || !mapRef.current) return if (!activeMarkerId || !mapRef.current) return
@@ -63,6 +63,11 @@ describe('MobileEntryCard', () => {
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/42/thumbnail') expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/42/thumbnail')
}) })
it('FE-COMP-JENTRYCARD-003b: a photo carrying only an id also resolves against the api', () => {
const { container } = renderCard(buildEntry({ photos: [{ id: 11 }] }))
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/11/thumbnail')
})
it('FE-COMP-JENTRYCARD-004: a public journey builds its own photo url', () => { it('FE-COMP-JENTRYCARD-004: a public journey builds its own photo url', () => {
const publicPhotoUrl = (id: number) => `/public/p/${id}.jpg` const publicPhotoUrl = (id: number) => `/public/p/${id}.jpg`
const { container } = renderCard(buildEntry({ photos: [{ photo_id: 7 }] }), { publicPhotoUrl }) const { container } = renderCard(buildEntry({ photos: [{ photo_id: 7 }] }), { publicPhotoUrl })
@@ -1,6 +1,6 @@
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react' import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
import { formatLocationName } from '../../utils/formatters' import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyEntry } from '../../store/journeyStore'
const MOOD_ICONS: Record<string, typeof Smile> = { const MOOD_ICONS: Record<string, typeof Smile> = {
amazing: Laugh, amazing: Laugh,
@@ -25,8 +25,14 @@ const WEATHER_ICONS: Record<string, typeof Sun> = {
cold: Snowflake, cold: Snowflake,
} }
function photoUrl(p: JourneyPhoto): string { // Shared journeys hand us photos that only carry `id`, the journey's own
return `/api/photos/${p.photo_id}/thumbnail` // photos carry `photo_id` — accept either.
function photoId(p: { photo_id?: number; id?: number }): number | undefined {
return p.photo_id ?? p.id
}
function photoUrl(id: number): string {
return `/api/photos/${id}/thumbnail`
} }
function stripMarkdown(text: string): string { function stripMarkdown(text: string): string {
@@ -53,11 +59,12 @@ export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, o
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : '' const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : ''
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null
const thumbSrc = firstPhoto const firstPhotoId = firstPhoto ? photoId(firstPhoto) : undefined
? publicPhotoUrl const thumbSrc = firstPhotoId == null
? publicPhotoUrl((firstPhoto as any).photo_id ?? (firstPhoto as any).id) ? null
: photoUrl(firstPhoto as JourneyPhoto) : publicPhotoUrl
: null ? publicPhotoUrl(firstPhotoId)
: photoUrl(firstPhotoId)
const date = new Date(entry.entry_date + 'T00:00:00') const date = new Date(entry.entry_date + 'T00:00:00')
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
@@ -1,9 +1,8 @@
import { useState } from 'react'
import { import {
X, Pencil, Trash2, MapPin, Clock, Camera, X, Pencil, Trash2, MapPin, Clock, Camera,
Laugh, Smile, Meh, Frown, Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown, ChevronDown, ThumbsUp, ThumbsDown,
} from 'lucide-react' } from 'lucide-react'
import JournalBody from './JournalBody' import JournalBody from './JournalBody'
import { formatLocationName } from '../../utils/formatters' import { formatLocationName } from '../../utils/formatters'
@@ -198,6 +198,25 @@ describe('MobileMapTimeline', () => {
} }
}) })
it('FE-COMP-JMAPTL-008b: markers arriving after the entries still get the initial focus', () => {
vi.useFakeTimers()
try {
const onEntryClick = vi.fn()
const { rerender } = render(
<MobileMapTimeline entries={entries} mapEntries={[]} onEntryClick={onEntryClick} />,
)
act(() => { vi.advanceTimersByTime(500) })
expect(mapHandle.focusMarker).not.toHaveBeenCalled()
rerender(<MobileMapTimeline entries={entries} mapEntries={mapEntries} onEntryClick={onEntryClick} />)
act(() => { vi.advanceTimersByTime(500) })
expect(mapHandle.focusMarker).toHaveBeenCalledWith('1')
} finally {
vi.useRealTimers()
}
})
it('FE-COMP-JMAPTL-009: an entry that has no marker clears the highlight instead', () => { it('FE-COMP-JMAPTL-009: an entry that has no marker clears the highlight instead', () => {
vi.useFakeTimers() vi.useFakeTimers()
try { try {
@@ -65,6 +65,12 @@ export default function MobileMapTimeline({
try { mapRef.current?.highlightMarker(null) } catch {} try { mapRef.current?.highlightMarker(null) } catch {}
} }
}, [entries, mapEntries]) }, [entries, mapEntries])
// The delayed initial focus reads both through refs so it always works off
// the current map entries, not the ones from the render that armed the timer.
const syncMapToCarouselRef = useRef(syncMapToCarousel)
syncMapToCarouselRef.current = syncMapToCarousel
const activeIndexRef = useRef(activeIndex)
activeIndexRef.current = activeIndex
// Pick the card that's currently closest to the carousel horizontal center. // Pick the card that's currently closest to the carousel horizontal center.
// More stable than IntersectionObserver thresholds when the active card can // More stable than IntersectionObserver thresholds when the active card can
@@ -129,13 +135,15 @@ export default function MobileMapTimeline({
} }
}, [activeIndex, onEntryClick, scrollCardIntoCenter]) }, [activeIndex, onEntryClick, scrollCardIntoCenter])
// Initial map focus — delay to let Leaflet initialize and fitBounds // Initial map focus — delay to let Leaflet initialize and fitBounds. Also
// re-runs when the markers arrive later than the entries, otherwise the
// focus would fire against an empty map and never be retried.
useEffect(() => { useEffect(() => {
if (entries.length > 0) { if (entries.length > 0) {
const timer = setTimeout(() => syncMapToCarousel(0), 500) const timer = setTimeout(() => syncMapToCarouselRef.current(activeIndexRef.current), 500)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, [entries.length]) }, [entries.length, mapEntries.length])
const activeEntryId = entries[activeIndex] const activeEntryId = entries[activeIndex]
? String(entries[activeIndex].id) ? String(entries[activeIndex].id)
@@ -1001,6 +1001,15 @@ describe('MapView photo thumbnails', () => {
expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toContain('/uploads/places/mine.jpg') expect(iconHtmlOf(screen.getAllByTestId('marker')[0])).toContain('/uploads/places/mine.jpg')
}) })
it('FE-COMP-MAPVIEW-076: a place with neither provider id nor coordinates has no cache key and is skipped', () => {
render(<MapView places={[
buildMapPlace({ id: 25, lat: null, lng: null, image_url: 'https://example.com/a.jpg' }),
buildMapPlace({ id: 26, lat: null, lng: null, image_url: 'https://example.com/b.jpg' }),
]} />)
expect(thumbCallbacks.size).toBe(0)
expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-071: photos are not fetched at all when the feature is off', () => { it('FE-COMP-MAPVIEW-071: photos are not fetched at all when the feature is off', () => {
useAuthStore.setState({ placesPhotosEnabled: false }) useAuthStore.setState({ placesPhotosEnabled: false })
render(<MapView places={[buildMapPlace({ id: 24, lat: 48, lng: 2, google_place_id: 'gp-24' })]} />) render(<MapView places={[buildMapPlace({ id: 24, lat: 48, lng: 2, google_place_id: 'gp-24' })]} />)
+7 -22
View File
@@ -36,15 +36,6 @@ L.Icon.Default.mergeOptions({
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png', shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
}) })
/**
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
*/
function escAttr(s) {
if (!s) return ''
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
const iconCache = new Map<string, L.DivIcon>() const iconCache = new Map<string, L.DivIcon>()
// Tone dot for a plugin route's via points (charging stops, rest areas) — smaller // Tone dot for a plugin route's via points (charging stops, rest areas) — smaller
@@ -73,6 +64,10 @@ function formatViaDwell(seconds: number): string {
return h > 0 ? `${h} h ${m} min` : `${m} min` return h > 0 ? `${h} h ${m} min` : `${m} min`
} }
/**
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
*/
function createPlaceIcon(place, orderNumbers, isSelected) { function createPlaceIcon(place, orderNumbers, isSelected) {
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}` const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
const cached = iconCache.get(cacheKey) const cached = iconCache.get(cacheKey)
@@ -329,16 +324,6 @@ interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null onClick: ((e: L.LeafletMouseEvent) => void) | null
} }
function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) {
const map = useMap()
useEffect(() => {
map.on('zoomstart', onZoomStart)
map.on('zoomend', onZoomEnd)
return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) }
}, [map, onZoomStart, onZoomEnd])
return null
}
const TRACK_CASING_PANE = 'trek-track-casing' const TRACK_CASING_PANE = 'trek-track-casing'
/** /**
@@ -385,7 +370,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
// Module-level photo cache shared with PlaceAvatar // Module-level photo cache shared with PlaceAvatar
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { isCustomPlaceImage } from './placePhoto' import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useGeolocation } from '../../hooks/useGeolocation' import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton' import LocationButton from './LocationButton'
@@ -647,7 +632,7 @@ export const MapView = memo(function MapView({
// photo for it (the request would 404 for OSM-only places and the fetched // photo for it (the request would 404 for OSM-only places and the fetched
// thumb would shadow the user's own image). (#1136) // thumb would shadow the user's own image). (#1136)
if (isCustomPlaceImage(place.image_url)) continue if (isCustomPlaceImage(place.image_url)) continue
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` const cacheKey = photoCacheKey(place)
if (!cacheKey) continue if (!cacheKey) continue
const cached = getCached(cacheKey) const cached = getCached(cacheKey)
@@ -693,7 +678,7 @@ export const MapView = memo(function MapView({
const markers = useMemo(() => places.map((place) => { const markers = useMemo(() => places.map((place) => {
const isSelected = place.id === selectedPlaceId const isSelected = place.id === selectedPlaceId
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` const pck = photoCacheKey(place)
// A custom uploaded image wins over the auto-fetched thumb; otherwise fall back. // A custom uploaded image wins over the auto-fetched thumb; otherwise fall back.
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null) const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
const orderNumbers = dayOrderMap[place.id] ?? null const orderNumbers = dayOrderMap[place.id] ?? null
@@ -909,6 +909,29 @@ describe('MapViewGL', () => {
expect(queryByTestId('tooltip')).toBeNull() expect(queryByTestId('tooltip')).toBeNull()
}) })
it('FE-COMP-MAPVIEWGL-067: hovering does not rebuild the markers under the cursor (#1404)', async () => {
loadOnAttach()
const places = [
buildMapPlace({ id: 41, lat: 48.41, lng: 2.41 }),
buildMapPlace({ id: 42, lat: 48.42, lng: 2.42 }),
]
const { queryByTestId } = render(<MapViewGL places={places} fitKey={1} />)
await act(async () => {})
const before = glMarkers.created.length
const el = glMarkers.created[0].element
act(() => { el.dispatchEvent(new MouseEvent('mouseenter', { clientX: 10, clientY: 10 })) })
await act(async () => {})
// The hover card is component state, so it re-renders MapViewGL. The omitted
// collection props have to keep their identity across that render — a fresh
// `[]`/`{}` default would re-run the reconcile effects and recreate every
// marker, and a marker recreated under the pointer never fires mouseleave.
expect(queryByTestId('tooltip')).toBeTruthy()
expect(glMarkers.created).toHaveLength(before)
})
it('FE-COMP-MAPVIEWGL-027: clicking a GPX track selects its place unless a marker or cluster is on top', async () => { it('FE-COMP-MAPVIEWGL-027: clicking a GPX track selects its place unless a marker or cluster is on top', async () => {
loadOnAttach() loadOnAttach()
const onMarkerClick = vi.fn() const onMarkerClick = vi.fn()
@@ -1588,6 +1611,17 @@ describe('MapViewGL', () => {
expect(photoService.fetchPhoto).toHaveBeenCalledWith('osm-5', '/api/maps/place-photo/abc', 48.5, 2.5, 'Museum') expect(photoService.fetchPhoto).toHaveBeenCalledWith('osm-5', '/api/maps/place-photo/abc', 48.5, 2.5, 'Museum')
}) })
it('FE-COMP-MAPVIEWGL-066: a place with neither provider id nor coordinates has no cache key and is skipped', async () => {
render(<MapViewGL places={[
buildMapPlace({ id: 98, lat: null, lng: null, image_url: 'https://example.com/a.jpg' }),
buildMapPlace({ id: 99, lat: null, lng: null, image_url: 'https://example.com/b.jpg' }),
]} fitKey={1} />)
await act(async () => {})
expect(photoService.fetchPhoto).not.toHaveBeenCalled()
expect(photoService.onThumbReady).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEWGL-057: no photos are fetched when place photos are switched off', async () => { it('FE-COMP-MAPVIEWGL-057: no photos are fetched when place photos are switched off', async () => {
useAuthStore.setState({ placesPhotosEnabled: false }) useAuthStore.setState({ placesPhotosEnabled: false })
+28 -14
View File
@@ -7,7 +7,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { isCustomPlaceImage } from './placePhoto' import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons' import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox' import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
@@ -73,6 +73,20 @@ interface RouteSegment {
drivingText?: string drivingText?: string
} }
// Stable identities for the omitted collection props. An inline `= []` / `= {}`
// default allocates a fresh object on every render, and these props sit in the
// dependency arrays of the imperative reconcile effects below — so every render,
// including one caused only by the hover tooltip's state, would tear down and
// rebuild every marker. That is the "marker recreated under the cursor,
// mouseleave never fires" case (#1404).
const NO_PLACES: Place[] = []
const NO_ROUTE_VIAS: RouteVia[] = []
const NO_ROUTE_SEGMENTS: RouteSegment[] = []
const NO_DAY_ORDER: Record<number, number[] | null> = {}
const NO_RESERVATIONS: Reservation[] = []
const NO_CONNECTION_IDS: number[] = []
const NO_POIS: Poi[] = []
interface Props { interface Props {
places: Place[] places: Place[]
dayPlaces?: Place[] dayPlaces?: Place[]
@@ -315,12 +329,12 @@ function createPoiMarkerElement(category: string): HTMLDivElement {
} }
export function MapViewGL({ export function MapViewGL({
places = [], places = NO_PLACES,
dayPlaces = [], dayPlaces = NO_PLACES,
tripId, tripId,
routeVias = [], routeVias = NO_ROUTE_VIAS,
route = null, route = null,
routeSegments = [], routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null, selectedPlaceId = null,
hoverDisabled = false, hoverDisabled = false,
onMarkerClick, onMarkerClick,
@@ -329,17 +343,17 @@ export function MapViewGL({
center = DEFAULT_MAP_CENTER, center = DEFAULT_MAP_CENTER,
zoom = DEFAULT_MAP_ZOOM, zoom = DEFAULT_MAP_ZOOM,
fitKey = 0, fitKey = 0,
dayOrderMap = {}, dayOrderMap = NO_DAY_ORDER,
leftWidth = 0, leftWidth = 0,
rightWidth = 0, rightWidth = 0,
hasInspector = false, hasInspector = false,
hasDayDetail = false, hasDayDetail = false,
reservations = [], reservations = NO_RESERVATIONS,
visibleConnectionIds = [], visibleConnectionIds = NO_CONNECTION_IDS,
showTransitRoutes = true, showTransitRoutes = true,
showReservationStats = false, showReservationStats = false,
onReservationClick, onReservationClick,
pois = [], pois = NO_POIS,
onPoiClick, onPoiClick,
onViewportChange, onViewportChange,
glProvider = 'mapbox-gl', glProvider = 'mapbox-gl',
@@ -816,12 +830,12 @@ export function MapViewGL({
if (ev.touches.length !== 1) { cancelLongPress(); return } if (ev.touches.length !== 1) { cancelLongPress(); return }
if ((ev.target as HTMLElement).closest('.mapboxgl-marker, .maplibregl-marker')) return if ((ev.target as HTMLElement).closest('.mapboxgl-marker, .maplibregl-marker')) return
const t = ev.touches[0] const t = ev.touches[0]
lpStart = { x: t.clientX, y: t.clientY } const start = { x: t.clientX, y: t.clientY }
lpStart = start
lpTimer = window.setTimeout(() => { lpTimer = window.setTimeout(() => {
lpTimer = null lpTimer = null
if (!lpStart) return
const rect = canvas.getBoundingClientRect() const rect = canvas.getBoundingClientRect()
const lngLat = map.unproject([lpStart.x - rect.left, lpStart.y - rect.top]) const lngLat = map.unproject([start.x - rect.left, start.y - rect.top])
lpStart = null lpStart = null
// Only suppress the tap when OUR fire opened the form — if the native // Only suppress the tap when OUR fire opened the form — if the native
// contextmenu beat us to it (dedupe), no click needs swallowing. // contextmenu beat us to it (dedupe), no click needs swallowing.
@@ -945,7 +959,7 @@ export function MapViewGL({
// photo for it (that request would 404 for OSM-only places and, worse, the // photo for it (that request would 404 for OSM-only places and, worse, the
// fetched thumb would shadow the user's own image). (#1136) // fetched thumb would shadow the user's own image). (#1136)
if (isCustomPlaceImage(place.image_url)) continue if (isCustomPlaceImage(place.image_url)) continue
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` const cacheKey = photoCacheKey(place)
if (!cacheKey) continue if (!cacheKey) continue
const cached = getCached(cacheKey) const cached = getCached(cacheKey)
if (cached?.thumbDataUrl) { if (cached?.thumbDataUrl) {
@@ -1002,7 +1016,7 @@ export function MapViewGL({
visiblePlaces.forEach(place => { visiblePlaces.forEach(place => {
const orderNumbers = dayOrderMap[place.id] ?? null const orderNumbers = dayOrderMap[place.id] ?? null
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` const pck = photoCacheKey(place)
// A custom image wins over the auto-fetched thumb; otherwise fall back to it. // A custom image wins over the auto-fetched thumb; otherwise fall back to it.
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null) const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
const selected = place.id === selectedPlaceId const selected = place.id === selectedPlaceId
@@ -0,0 +1,60 @@
// FE-COMP-POIPILL-001 to FE-COMP-POIPILL-006
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import { fireEvent } from '@testing-library/react'
import PoiCategoryPill from './PoiCategoryPill'
import { POI_CATEGORIES } from './poiCategories'
const CAFE = POI_CATEGORIES.find(c => c.key === 'cafe')!
const BAR = POI_CATEGORIES.find(c => c.key === 'bar')!
function pill(props: Partial<React.ComponentProps<typeof PoiCategoryPill>> = {}) {
return render(
<PoiCategoryPill active={new Set()} onToggle={vi.fn()} {...props} />,
)
}
// The spinner replaces the category icon, so "is this segment spinning" reads as
// "does its button hold an .animate-spin element".
const spinning = () => screen.getAllByRole('button')
.filter(b => b.querySelector('.animate-spin'))
.map(b => b.getAttribute('aria-label'))
describe('PoiCategoryPill', () => {
it('FE-COMP-POIPILL-001: renders one segment per category, none pressed', () => {
pill()
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(POI_CATEGORIES.length)
expect(buttons.every(b => b.getAttribute('aria-pressed') === 'false')).toBe(true)
})
it('FE-COMP-POIPILL-002: clicking a segment toggles its category', () => {
const onToggle = vi.fn()
pill({ onToggle })
fireEvent.click(screen.getAllByRole('button')[1])
expect(onToggle).toHaveBeenCalledWith(POI_CATEGORIES[1].key)
})
it('FE-COMP-POIPILL-003: the active category spins while its fetch is in flight', () => {
pill({ active: new Set([CAFE.key]), loadingKeys: new Set([CAFE.key]) })
expect(spinning()).toEqual(['Cafés'])
})
it('FE-COMP-POIPILL-004: a deselected category never spins, even with a lingering loading key', () => {
pill({ active: new Set([BAR.key]), loadingKeys: new Set([CAFE.key, BAR.key]) })
expect(spinning()).toEqual(['Bars & nightlife'])
})
it('FE-COMP-POIPILL-005: a failed category offers a retry', () => {
pill({ active: new Set([CAFE.key]), errorKeys: new Set([CAFE.key]) })
expect(screen.getByText('Search this area')).toBeInTheDocument()
})
it('FE-COMP-POIPILL-006: "search this area" reports the moved viewport', () => {
const onSearchArea = vi.fn()
pill({ active: new Set([CAFE.key]), moved: true, onSearchArea })
fireEvent.click(screen.getByText('Search this area'))
expect(onSearchArea).toHaveBeenCalled()
})
})
@@ -33,7 +33,9 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKe
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted }}>
{POI_CATEGORIES.map(cat => { {POI_CATEGORIES.map(cat => {
const on = active.has(cat.key) const on = active.has(cat.key)
const loading = loadingKeys?.has(cat.key) // Only an active category can be loading — a deselected one whose fetch
// is still winding down must not keep spinning.
const loading = on && !!loadingKeys?.has(cat.key)
return ( return (
<Tooltip key={cat.key} label={t(cat.labelKey)} placement="bottom"> <Tooltip key={cat.key} label={t(cat.labelKey)} placement="bottom">
<button <button
+10 -182
View File
@@ -1,4 +1,4 @@
import { Fragment, createElement, useEffect, useMemo, useRef, useState } from 'react' import { Fragment, createElement, useMemo, useState } from 'react'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet' import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet' import L from 'leaflet'
@@ -10,8 +10,6 @@ import { useSettingsStore } from '../../store/settingsStore'
import type { Reservation, ReservationEndpoint } from '../../types' import type { Reservation, ReservationEndpoint } from '../../types'
const ENDPOINT_PANE = 'reservation-endpoints' const ENDPOINT_PANE = 'reservation-endpoints'
const AIRPORT_BADGE_HALF_PX = 16
const BADGE_GAP_PX = 5
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other' type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other'] const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other']
@@ -46,7 +44,7 @@ function useEndpointPane() {
function endpointIcon(type: TransportType, label: string | null): L.DivIcon { function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
const { icon: IconCmp, color } = TYPE_META[type] const { icon: IconCmp, color } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 })) const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span>${escapeHtml(label)}</span>` : '' const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26 const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
return L.divIcon({ return L.divIcon({
className: 'trek-endpoint-marker', className: 'trek-endpoint-marker',
@@ -57,7 +55,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
border:1.5px solid #fff;color:#fff; border:1.5px solid #fff;color:#fff;
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1; font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
box-sizing:border-box;height:22px;white-space:nowrap; box-sizing:border-box;height:22px;white-space:nowrap;
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''}</div>`, "><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`,
iconSize: [estWidth, 22], iconSize: [estWidth, 22],
iconAnchor: [estWidth / 2, 11], iconAnchor: [estWidth / 2, 11],
popupAnchor: [0, -11], popupAnchor: [0, -11],
@@ -135,182 +133,18 @@ interface TransportItem {
type: TransportType type: TransportType
arcs: [number, number][][] arcs: [number, number][][]
transitSegs: TransitMapSegment[] transitSegs: TransitMapSegment[]
primaryArc: [number, number][] // Route ("VIE → LHR") and duration/distance line. Computed on every update but
fallback: [number, number] // not drawn since the stats badge was dropped; computeDuration still guards the
// non-finite date that used to blank the trip (#1620).
mainLabel: string | null mainLabel: string | null
subLabel: string | null subLabel: string | null
} }
function buildStatsHtml(color: string, mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
const estWidth = Math.max(
mainLabel ? mainLabel.length * 6.5 : 0,
subLabel ? subLabel.length * 5.5 : 0,
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${escapeHtml(subLabel)}</span>` : ''
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
padding:0 11px;border-radius:999px;
background:rgba(17,24,39,0.92);color:#fff;
box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1px solid ${color}aa;
font-family:var(--font-system);
white-space:nowrap;box-sizing:border-box;
transform-origin:center;
will-change:transform;
">${main}${sub}</div>`
return { html, width: estWidth, height }
}
function StatsLabel({ item }: { item: TransportItem }) {
const map = useMap()
const markerRef = useRef<L.Marker | null>(null)
const innerRef = useRef<HTMLElement | null>(null)
const arc = item.primaryArc
const color = TYPE_META[item.type].color
const { html, width, height } = useMemo(() => buildStatsHtml(color, item.mainLabel, item.subLabel), [color, item.mainLabel, item.subLabel])
const buffer = AIRPORT_BADGE_HALF_PX + width / 2 + BADGE_GAP_PX
const compute = () => {
if (arc.length < 2) return null
const size = map.getSize()
const pts = arc.map(p => map.latLngToContainerPoint(p as L.LatLngTuple))
const cum: number[] = [0]
let total = 0
for (let i = 1; i < pts.length; i++) {
total += pts[i].distanceTo(pts[i - 1])
cum.push(total)
}
if (total <= 0) return null
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const isIn = (p: L.Point) => {
if (p.x < -40 || p.x > size.x + 40 || p.y < -40 || p.y > size.y + 40) return false
if (p.distanceTo(fromPx) < buffer) return false
if (p.distanceTo(toPx) < buffer) return false
return true
}
let firstIdx = -1
let lastIdx = -1
for (let i = 0; i < pts.length; i++) {
if (isIn(pts[i])) {
if (firstIdx < 0) firstIdx = i
lastIdx = i
}
}
if (firstIdx < 0) {
const target = total / 2
let sIdx = 0
while (sIdx < cum.length - 2 && cum[sIdx + 1] < target) sIdx++
const span = cum[sIdx + 1] - cum[sIdx]
const tm = span > 0 ? (target - cum[sIdx]) / span : 0
const pA = pts[sIdx]
const pB = pts[sIdx + 1]
const mx = pA.x + (pB.x - pA.x) * tm
const my = pA.y + (pB.y - pA.y) * tm
const latlng = map.containerPointToLatLng([mx, my])
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
}
const bisectFraction = (a: L.Point, b: L.Point) => {
let lo = 0, hi = 1
for (let k = 0; k < 10; k++) {
const mid = (lo + hi) / 2
const mp = L.point(a.x + (b.x - a.x) * mid, a.y + (b.y - a.y) * mid)
if (isIn(mp)) hi = mid
else lo = mid
}
return (lo + hi) / 2
}
let lowCum = cum[firstIdx]
if (firstIdx > 0) {
const t = bisectFraction(pts[firstIdx - 1], pts[firstIdx])
lowCum = cum[firstIdx - 1] + (cum[firstIdx] - cum[firstIdx - 1]) * t
}
let highCum = cum[lastIdx]
if (lastIdx < pts.length - 1) {
const t = bisectFraction(pts[lastIdx + 1], pts[lastIdx])
highCum = cum[lastIdx] + (cum[lastIdx + 1] - cum[lastIdx]) * (1 - t)
}
const targetLen = (lowCum + highCum) / 2
let segIdx = 0
while (segIdx < cum.length - 2 && cum[segIdx + 1] < targetLen) segIdx++
const segSpan = cum[segIdx + 1] - cum[segIdx]
const t = segSpan > 0 ? (targetLen - cum[segIdx]) / segSpan : 0
const pA = pts[segIdx]
const pB = pts[segIdx + 1]
const px = pA.x + (pB.x - pA.x) * t
const py = pA.y + (pB.y - pA.y) * t
const latlng = map.containerPointToLatLng([px, py])
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
}
const apply = () => {
const pose = compute()
const marker = markerRef.current
if (!marker) return
const el = marker.getElement() as HTMLElement | null
if (!pose) {
if (el) el.style.display = 'none'
return
}
if (el) el.style.display = ''
marker.setLatLng(pose.point as L.LatLngTuple)
if (!innerRef.current && el) innerRef.current = el.querySelector('.trek-stats-inner') as HTMLElement | null
if (innerRef.current) innerRef.current.style.transform = `rotate(${pose.angle}deg)`
}
useEffect(() => {
const icon = L.divIcon({
className: 'trek-endpoint-stats',
html,
iconSize: [width, height],
iconAnchor: [width / 2, height / 2],
})
const marker = L.marker([0, 0], { icon, pane: ENDPOINT_PANE, interactive: false, keyboard: false })
marker.addTo(map)
markerRef.current = marker
innerRef.current = null
apply()
return () => {
marker.remove()
markerRef.current = null
innerRef.current = null
}
}, [map, html, width, height])
useMapEvents({
move: apply,
zoom: apply,
viewreset: apply,
resize: apply,
})
return null
}
interface Props { interface Props {
reservations: Reservation[] reservations: Reservation[]
showConnections: boolean showConnections: boolean
// Accepted for call-site compatibility only: the floating route/duration badge
// on the arc is no longer drawn, so nothing is gated on this.
showStats: boolean showStats: boolean
onEndpointClick?: (reservationId: number) => void onEndpointClick?: (reservationId: number) => void
// Real road-network geometry for car/bus/taxi/bicycle bookings, keyed by // Real road-network geometry for car/bus/taxi/bicycle bookings, keyed by
@@ -318,7 +152,7 @@ interface Props {
roadRoutes?: Map<number, [number, number][]> roadRoutes?: Map<number, [number, number][]>
} }
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick, roadRoutes }: Props) { export default function ReservationOverlay({ reservations, showConnections, onEndpointClick, roadRoutes }: Props) {
useEndpointPane() useEndpointPane()
const map = useMap() const map = useMap()
const [zoom, setZoom] = useState(() => map.getZoom()) const [zoom, setZoom] = useState(() => map.getZoom())
@@ -354,12 +188,6 @@ export default function ReservationOverlay({ reservations, showConnections, show
arcs.push(...segArcs) arcs.push(...segArcs)
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng]) distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
} }
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const fallback: [number, number] = primaryArc.length > 0
? (primaryArc[Math.floor(primaryArc.length / 2)] ?? [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2])
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null) const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(distanceKm)} km` const distance = `${Math.round(distanceKm)} km`
// Show the full route (FRA → BER → HND) when every waypoint has a code. // Show the full route (FRA → BER → HND) when every waypoint has a code.
@@ -369,7 +197,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
const subParts = [duration, distance].filter(Boolean) as string[] const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, waypoints, type, arcs, transitSegs: type === 'transit' ? getTransitMapSegments(r) : [], primaryArc, fallback, mainLabel, subLabel }) out.push({ res: r, from, to, waypoints, type, arcs, transitSegs: type === 'transit' ? getTransitMapSegments(r) : [], mainLabel, subLabel })
} }
return out return out
}, [reservations]) }, [reservations])
+18
View File
@@ -8,3 +8,21 @@
export function isCustomPlaceImage(url: string | null | undefined): boolean { export function isCustomPlaceImage(url: string | null | undefined): boolean {
return !!url && (url.startsWith('/uploads/') || url.startsWith('data:')) return !!url && (url.startsWith('/uploads/') || url.startsWith('data:'))
} }
/**
* Cache key under which a place's auto-fetched thumbnail is stored: the provider
* id when there is one, otherwise the coordinates. A place with neither has no
* identity to cache under, so it gets the empty string — callers skip it rather
* than filing every coordinate-less place under one shared entry.
*/
export function photoCacheKey(place: {
google_place_id?: string | null
osm_id?: string | null
lat?: number | null
lng?: number | null
}): string {
if (place.google_place_id) return place.google_place_id
if (place.osm_id) return place.osm_id
if (place.lat == null || place.lng == null) return ''
return `${place.lat},${place.lng}`
}
@@ -264,7 +264,6 @@ describe('ReservationMapboxOverlay lifecycle', () => {
expect(glMarkers.every(m => m.remove.mock.calls.length > 0)).toBe(true) expect(glMarkers.every(m => m.remove.mock.calls.length > 0)).toBe(true)
expect(map.listeners('zoomend')).toBe(0) expect(map.listeners('zoomend')).toBe(0)
expect(map.listeners('moveend')).toBe(0) expect(map.listeners('moveend')).toBe(0)
expect(map.listeners('render')).toBe(0)
expect(map.removeLayer).toHaveBeenCalledWith(RESERVATION_LINE_LAYER_ID) expect(map.removeLayer).toHaveBeenCalledWith(RESERVATION_LINE_LAYER_ID)
expect(map.removeSource).toHaveBeenCalledWith(RESERVATION_SOURCE_ID) expect(map.removeSource).toHaveBeenCalledWith(RESERVATION_SOURCE_ID)
}) })
@@ -287,20 +286,18 @@ describe('ReservationMapboxOverlay lifecycle', () => {
expect(features(map)).toHaveLength(1) expect(features(map)).toHaveLength(1)
}) })
it('FE-COMP-RESGL-006: the redraw and rotation handlers are inert once the overlay is destroyed', () => { it('FE-COMP-RESGL-006: the redraw handler is inert once the overlay is destroyed', () => {
const map = freshMap() const map = freshMap()
const overlay = attach(map) const overlay = attach(map)
const handlerFor = (event: string) => const handlerFor = (event: string) =>
map.on.mock.calls.find(c => c[0] === event)?.[1] as Handler map.on.mock.calls.find(c => c[0] === event)?.[1] as Handler
const rerender = handlerFor('moveend') const rerender = handlerFor('moveend')
const rotate = handlerFor('render')
overlay.update([booking()], opts) overlay.update([booking()], opts)
const drawsBefore = map._sources.get(RESERVATION_SOURCE_ID)!.setData.mock.calls.length const drawsBefore = map._sources.get(RESERVATION_SOURCE_ID)!.setData.mock.calls.length
overlay.destroy() overlay.destroy()
rerender() rerender()
rotate()
expect(map._sources.get(RESERVATION_SOURCE_ID)).toBeUndefined() expect(map._sources.get(RESERVATION_SOURCE_ID)).toBeUndefined()
expect(drawsBefore).toBeGreaterThan(0) expect(drawsBefore).toBeGreaterThan(0)
}) })
@@ -700,7 +697,7 @@ describe('ReservationMapboxOverlay transit styling', () => {
}) })
describe('ReservationMapboxOverlay frame handler', () => { describe('ReservationMapboxOverlay frame handler', () => {
it('FE-COMP-RESGL-042: a paint frame rotates only — it does not redraw the overlay', () => { it('FE-COMP-RESGL-042: the overlay does not subscribe to paint frames — only camera events redraw it', () => {
const map = freshMap() const map = freshMap()
const overlay = attach(map) const overlay = attach(map)
overlay.update([booking()], opts) overlay.update([booking()], opts)
@@ -708,6 +705,7 @@ describe('ReservationMapboxOverlay frame handler', () => {
map.fire('render') map.fire('render')
expect(map.listeners('render')).toBe(0)
expect(map._sources.get(RESERVATION_SOURCE_ID)!.setData.mock.calls).toHaveLength(drawsBefore) expect(map._sources.get(RESERVATION_SOURCE_ID)!.setData.mock.calls).toHaveLength(drawsBefore)
expect(glMarkers).toHaveLength(2) expect(glMarkers).toHaveLength(2)
expect(glMarkers.every(m => m.remove.mock.calls.length === 0)).toBe(true) expect(glMarkers.every(m => m.remove.mock.calls.length === 0)).toBe(true)
@@ -102,7 +102,9 @@ interface TransportItem {
waypoints: ReservationEndpoint[] waypoints: ReservationEndpoint[]
type: TransportType type: TransportType
arcs: [number, number][][] arcs: [number, number][][]
primaryArc: [number, number][] // Route ("VIE → LHR") and duration/distance line. Computed on every update but
// not drawn since the stats badge was dropped; computeDuration still guards the
// non-finite date that used to blank the trip (#1620).
mainLabel: string | null mainLabel: string | null
subLabel: string | null subLabel: string | null
} }
@@ -136,8 +138,6 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
arcs.push(...segArcs) arcs.push(...segArcs)
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng]) distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
} }
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null) const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(distanceKm)} km` const distance = `${Math.round(distanceKm)} km`
const mainLabel = waypoints.every(w => w.code) const mainLabel = waypoints.every(w => w.code)
@@ -145,7 +145,7 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
: (from.code && to.code ? `${from.code} → ${to.code}` : null) : (from.code && to.code ? `${from.code} → ${to.code}` : null)
const subParts = [duration, distance].filter(Boolean) as string[] const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, mainLabel, subLabel }) out.push({ res: r, from, to, waypoints, type, arcs, mainLabel, subLabel })
} }
return out return out
} }
@@ -165,32 +165,11 @@ function endpointMarkerHtml(type: TransportType, label: string | null): string {
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>` "><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`
} }
function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
const estWidth = Math.max(
mainLabel ? mainLabel.length * 6.5 : 0,
subLabel ? subLabel.length * 5.5 : 0,
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${escapeHtml(subLabel)}</span>` : ''
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
padding:0 11px;border-radius:999px;
background:rgba(17,24,39,0.92);color:#fff;
box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1px solid ${TRANSPORT_COLOR}aa;
font-family:var(--font-system);
white-space:nowrap;box-sizing:border-box;pointer-events:none;
transform-origin:center;will-change:transform;
">${main}${sub}</div>`
return { html, width: estWidth, height }
}
// ── overlay manager ────────────────────────────────────────────────────── // ── overlay manager ──────────────────────────────────────────────────────
export interface ReservationOverlayOptions { export interface ReservationOverlayOptions {
showConnections: boolean showConnections: boolean
// Accepted for call-site compatibility only: the floating route/duration badge
// on the arc is no longer drawn, so nothing is gated on this.
showStats: boolean showStats: boolean
showEndpointLabels: boolean showEndpointLabels: boolean
onEndpointClick?: (reservationId: number) => void onEndpointClick?: (reservationId: number) => void
@@ -212,7 +191,6 @@ export class ReservationMapboxOverlay {
private opts: ReservationOverlayOptions private opts: ReservationOverlayOptions
private MarkerCtor: MarkerConstructor private MarkerCtor: MarkerConstructor
private endpointMarkers: GlMarker[] = [] private endpointMarkers: GlMarker[] = []
private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = []
private rerender: () => void private rerender: () => void
private destroyed = false private destroyed = false
@@ -224,7 +202,6 @@ export class ReservationMapboxOverlay {
this.setupLayer() this.setupLayer()
map.on('zoomend', this.rerender) map.on('zoomend', this.rerender)
map.on('moveend', this.rerender) map.on('moveend', this.rerender)
map.on('render', this.updateStatsRotation)
} }
update(reservations: Reservation[], opts: ReservationOverlayOptions, roadRoutes?: Map<number, [number, number][]>) { update(reservations: Reservation[], opts: ReservationOverlayOptions, roadRoutes?: Map<number, [number, number][]>) {
@@ -238,11 +215,8 @@ export class ReservationMapboxOverlay {
this.destroyed = true this.destroyed = true
this.map.off('zoomend', this.rerender) this.map.off('zoomend', this.rerender)
this.map.off('moveend', this.rerender) this.map.off('moveend', this.rerender)
this.map.off('render', this.updateStatsRotation)
this.endpointMarkers.forEach(m => m.remove()) this.endpointMarkers.forEach(m => m.remove())
this.endpointMarkers = [] this.endpointMarkers = []
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
try { try {
if (this.map.getLayer(RESERVATION_LINE_LAYER_ID)) this.map.removeLayer(RESERVATION_LINE_LAYER_ID) if (this.map.getLayer(RESERVATION_LINE_LAYER_ID)) this.map.removeLayer(RESERVATION_LINE_LAYER_ID)
if (this.map.getSource(RESERVATION_SOURCE_ID)) this.map.removeSource(RESERVATION_SOURCE_ID) if (this.map.getSource(RESERVATION_SOURCE_ID)) this.map.removeSource(RESERVATION_SOURCE_ID)
@@ -386,34 +360,7 @@ export class ReservationMapboxOverlay {
} }
} }
// Stats badge removed — the floating route/duration label on the arc is no // No stats badge: the floating route/duration label on the arc was removed,
// longer drawn; only the connection line and the airport markers remain. // so a rendered overlay is the connection lines plus the airport markers.
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
}
// Match the Leaflet overlay's "rotate the label along the arc" look.
// We pick a short segment straddling the arc midpoint, measure the
// screen angle between those two projected points, and clamp it to
// [-90°, 90°] so text never renders upside-down.
private updateStatsRotation = () => {
if (this.destroyed) return
for (const entry of this.statsMarkers) {
const { marker, arc } = entry
if (arc.length < 2) continue
const midIdx = Math.floor(arc.length / 2)
const a = arc[Math.max(0, midIdx - 2)]!
const b = arc[Math.min(arc.length - 1, midIdx + 2)]!
try {
const pa = this.map.project([a[1], a[0]])
const pb = this.map.project([b[1], b[0]])
let angle = Math.atan2(pb.y - pa.y, pb.x - pa.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
const el = marker.getElement()
const inner = el.querySelector('.trek-stats-inner') as HTMLElement | null
if (inner) inner.style.transform = `rotate(${angle}deg)`
} catch { /* map not ready / projection failure */ }
}
} }
} }
@@ -1,4 +1,4 @@
// FE-COMP-POIEXPLORE-001 to FE-COMP-POIEXPLORE-016 // FE-COMP-POIEXPLORE-001 to FE-COMP-POIEXPLORE-018
import React from 'react' import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react' import { act, renderHook, waitFor } from '@testing-library/react'
@@ -240,4 +240,34 @@ describe('usePoiExplore', () => {
expect(result.current.errorKeys.size).toBe(0) expect(result.current.errorKeys.size).toBe(0)
expect(result.current.pois[0].osm_id).toBe('node/9') expect(result.current.pois[0].osm_id).toBe('node/9')
}) })
it('FE-COMP-POIEXPLORE-017: the abandoned category stops loading when the selection moves on', async () => {
const first = deferred<PoiResponse>()
pois.mockReturnValueOnce(first.promise)
const { result } = setup()
act(() => { result.current.onViewportChange(BBOX) })
act(() => { result.current.toggle('cafe') })
expect(result.current.loadingKeys.has('cafe')).toBe(true)
pois.mockResolvedValue(response([poi({ osm_id: 'node/9', category: 'bar' })]))
act(() => { result.current.toggle('bar') })
await act(async () => { first.reject(Object.assign(new Error('canceled'), { name: 'CanceledError' })) })
await waitFor(() => expect(result.current.loadingKeys.has('cafe')).toBe(false))
expect(result.current.loadingKeys.has('bar')).toBe(false)
})
it('FE-COMP-POIEXPLORE-018: turning the category off stops its spinner as well', async () => {
const d = deferred<PoiResponse>()
pois.mockReturnValue(d.promise)
const { result } = setup()
act(() => { result.current.onViewportChange(BBOX) })
act(() => { result.current.toggle('cafe') })
act(() => { result.current.toggle('cafe') })
await act(async () => { d.reject(Object.assign(new Error('canceled'), { name: 'CanceledError' })) })
await waitFor(() => expect(result.current.loadingKeys.size).toBe(0))
})
}) })
@@ -74,6 +74,11 @@ export function usePoiExplore() {
if (abortRef.current[key] === ctrl) { if (abortRef.current[key] === ctrl) {
setLoading(key, false) setLoading(key, false)
delete abortRef.current[key] delete abortRef.current[key]
} else if (!abortRef.current[key]) {
// Cancelled with nothing taking over (toggle switched the category, or
// turned it off) — no later request will clear this key, so do it here
// instead of leaving the pill spinning forever.
setLoading(key, false)
} }
} }
}, [setLoading, setError, locale]) }, [setLoading, setError, locale])
+12
View File
@@ -899,4 +899,16 @@ describe('downloadTripPDF defaults', () => {
expect(endSection).toContain('reservations.span.end: Overnight Bus') expect(endSection).toContain('reservations.span.end: Overnight Bus')
expect(endSection).not.toContain('22:00') expect(endSection).not.toContain('22:00')
}) })
it('FE-W5PDF-027: an export without a locale still renders, with lang="en"', async () => {
await downloadTripPDF({ ...minimalArgs, locale: '' })
expect(srcdoc()).toContain('<html lang="en">')
})
it('FE-W5PDF-028: an export without assignments renders the days as empty', async () => {
await downloadTripPDF({ ...minimalArgs, assignments: undefined as any })
expect(srcdoc()).toContain('Day 1')
})
}) })
+6 -4
View File
@@ -146,7 +146,9 @@ interface downloadTripPDFProps {
locale: string locale: string
} }
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) { // `assignments` is normalised here once — every read below (and fetchPlacePhotos)
// relies on it being an object.
export async function downloadTripPDF({ trip, days, places, assignments = {}, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) {
await ensureRenderer() await ensureRenderer()
const loc = _locale || undefined const loc = _locale || undefined
const tr = _t || (k => k) const tr = _t || (k => k)
@@ -165,7 +167,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const photoMap = await fetchPlacePhotos(assignments, places) const photoMap = await fetchPlacePhotos(assignments, places)
const totalAssigned = new Set( const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean) Object.values(assignments).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
).size ).size
// The PDF is a trip-scoped, shareable document, so totals stay in the trip's // The PDF is a trip-scoped, shareable document, so totals stay in the trip's
// own currency. Rates are resolved ONCE before any HTML is built so the cover // own currency. Rates are resolved ONCE before any HTML is built so the cover
@@ -173,7 +175,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
// entirely (offline export keeps working), and a failed fetch degrades to // entirely (offline export keeps working), and a failed fetch degrades to
// per-currency breakdowns instead of mislabeled sums (#1561). // per-currency breakdowns instead of mislabeled sums (#1561).
const tripCur = (trip?.currency || 'EUR').toUpperCase() const tripCur = (trip?.currency || 'EUR').toUpperCase()
const allCostEntries: MoneyEntry[] = Object.values(assignments || {}) const allCostEntries: MoneyEntry[] = Object.values(assignments)
.flatMap(a => a) .flatMap(a => a)
.map(a => ({ amount: Number(a.place?.price) || 0, currency: a.place?.currency || tripCur })) .map(a => ({ amount: Number(a.place?.price) || 0, currency: a.place?.currency || tripCur }))
const needsFx = allCostEntries.some(e => e.amount > 0 && e.currency.toUpperCase() !== tripCur) const needsFx = allCostEntries.some(e => e.amount > 0 && e.currency.toUpperCase() !== tripCur)
@@ -433,7 +435,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
</div>` </div>`
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="${loc.split('-')[0]}"> <html lang="${(loc || 'en').split('-')[0]}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<base href="${window.location.origin}/"> <base href="${window.location.origin}/">
@@ -229,24 +229,28 @@ describe('KategorieGruppe — bulk actions', () => {
await waitFor(() => expect(puts).toEqual([1])) await waitFor(() => expect(puts).toEqual([1]))
}) })
it('FE-W5CAT-012: a throwing toggle during Check All surfaces a save error', async () => { it('FE-W5CAT-012: a failing PUT during Check All is reported by the store, once', async () => {
useTripStore.setState({ togglePackingItem: async () => { throw new Error('offline') } }) server.use(http.put('/api/trips/1/packing/:itemId', () => new HttpResponse(null, { status: 500 })))
seedStore(useTripStore, { packingItems: [buildPackingItem({ id: 1, name: 'Tent', checked: 0 })] })
const { container } = setup({ items: [buildPackingItem({ id: 1, name: 'Tent', checked: 0 })] }) const { container } = setup({ items: [buildPackingItem({ id: 1, name: 'Tent', checked: 0 })] })
openCategoryMenu(container) openCategoryMenu(container)
fireEvent.click(screen.getByText('Check All')) fireEvent.click(screen.getByText('Check All'))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined)) await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Error updating item', 'error', undefined))
expect(toastSpy).toHaveBeenCalledTimes(1)
}) })
it('FE-W5CAT-013: a throwing toggle during Uncheck All surfaces a save error', async () => { it('FE-W5CAT-013: a failing PUT during Uncheck All is reported by the store, once', async () => {
useTripStore.setState({ togglePackingItem: async () => { throw new Error('offline') } }) server.use(http.put('/api/trips/1/packing/:itemId', () => new HttpResponse(null, { status: 500 })))
seedStore(useTripStore, { packingItems: [buildPackingItem({ id: 1, name: 'Tent', checked: 1 })] })
const { container } = setup({ items: [buildPackingItem({ id: 1, name: 'Tent', checked: 1 })] }) const { container } = setup({ items: [buildPackingItem({ id: 1, name: 'Tent', checked: 1 })] })
openCategoryMenu(container) openCategoryMenu(container)
fireEvent.click(screen.getByText('Uncheck All')) fireEvent.click(screen.getByText('Uncheck All'))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined)) await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Error updating item', 'error', undefined))
expect(toastSpy).toHaveBeenCalledTimes(1)
}) })
it('FE-W5CAT-014: Delete List hands the whole category to the panel and closes the menu', async () => { it('FE-W5CAT-014: Delete List hands the whole category to the panel and closes the menu', async () => {
@@ -98,19 +98,17 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
catch { toast.error(t('packing.toast.renameError')) } catch { toast.error(t('packing.toast.renameError')) }
} }
// togglePackingItem rolls its own optimistic update back and reports the
// failure itself, so the bulk actions just drive it item by item.
const handleCheckAll = async () => { const handleCheckAll = async () => {
try { for (const item of Array.from(items)) {
for (const item of Array.from(items)) { if (!item.checked) await togglePackingItem(tripId, item.id, true)
if (!item.checked) await togglePackingItem(tripId, item.id, true) }
}
} catch { toast.error(t('packing.toast.saveError')) }
} }
const handleUncheckAll = async () => { const handleUncheckAll = async () => {
try { for (const item of Array.from(items)) {
for (const item of Array.from(items)) { if (item.checked) await togglePackingItem(tripId, item.id, false)
if (item.checked) await togglePackingItem(tripId, item.id, false) }
}
} catch { toast.error(t('packing.toast.saveError')) }
} }
const handleDeleteAll = async () => { const handleDeleteAll = async () => {
await onDeleteAll(items) await onDeleteAll(items)
@@ -304,7 +302,8 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
placeholder={t('packing.addItemPlaceholder')} placeholder={t('packing.addItemPlaceholder')}
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }} style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }}
/> />
<button onClick={() => { if (newItemName.trim()) { onAddItem(kategorie, newItemName.trim()); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) } }} {/* disabled while the field is empty, so no extra guard here */}
<button onClick={() => { onAddItem(kategorie, newItemName.trim()); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
disabled={!newItemName.trim()} disabled={!newItemName.trim()}
style={{ padding: '5px 8px', borderRadius: 8, border: 'none', background: newItemName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newItemName.trim() ? 'pointer' : 'default', display: 'flex' }}> style={{ padding: '5px 8px', borderRadius: 8, border: 'none', background: newItemName.trim() ? 'var(--text-primary)' : 'var(--border-primary)', color: 'var(--bg-primary)', cursor: newItemName.trim() ? 'pointer' : 'default', display: 'flex' }}>
<Plus size={14} /> <Plus size={14} />
@@ -120,7 +120,7 @@ function Row({ icon, label, sub, active, onClick }: { icon: React.ReactNode; lab
<button onClick={onClick} <button onClick={onClick}
style={{ display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%', padding: '7px 10px', borderRadius: 7, border: 'none', cursor: 'pointer', background: active ? 'var(--bg-tertiary)' : 'none', fontFamily: 'inherit', textAlign: 'left' }} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%', padding: '7px 10px', borderRadius: 7, border: 'none', cursor: 'pointer', background: active ? 'var(--bg-tertiary)' : 'none', fontFamily: 'inherit', textAlign: 'left' }}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--bg-tertiary)' }} onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = active ? 'var(--bg-tertiary)' : 'none' }}> onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'none' }}>
<span style={{ color: active ? 'var(--accent)' : 'var(--text-muted)', marginTop: 1 }}>{icon}</span> <span style={{ color: active ? 'var(--accent)' : 'var(--text-muted)', marginTop: 1 }}>{icon}</span>
<span style={{ flex: 1, minWidth: 0 }}> <span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{label}</span> <span style={{ display: 'block', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{label}</span>
@@ -101,7 +101,7 @@ export default function AddressInput({ value, onChange, placeholder, className }
<MapPin size={12} className="text-content-faint" style={{ marginTop: 2, flexShrink: 0 }} /> <MapPin size={12} className="text-content-faint" style={{ marginTop: 2, flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0 }}> <span style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name || r.address}</div> <div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name || r.address}</div>
{r.address && r.name !== r.address && ( {r.address && r.name && r.name !== r.address && (
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.address}</div> <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.address}</div>
)} )}
</span> </span>
@@ -71,8 +71,9 @@ export default function AirportSelect({ value, onChange, placeholder, style }: P
const data = await airportsApi.search(trimmed, controller.signal) const data = await airportsApi.search(trimmed, controller.signal)
setResults(Array.isArray(data) ? data : []) setResults(Array.isArray(data) ? data : [])
setHighlight(-1) setHighlight(-1)
} catch (err: any) { } catch (err) {
if (err?.name !== 'AbortError' && err?.name !== 'CanceledError') { const name = (err as { name?: string } | null)?.name
if (name !== 'AbortError' && name !== 'CanceledError') {
setResults([]) setResults([])
} }
} finally { } finally {
@@ -378,66 +378,6 @@ function Chip({ icon: Icon, value }: ChipProps) {
) )
} }
interface InfoChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
label: string
value: string
placeholder: string
onEdit: (value: string) => void
type: 'text' | 'time'
}
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }: InfoChipProps) {
const [editing, setEditing] = React.useState(false)
const [val, setVal] = React.useState(value || '')
const inputRef = React.useRef(null)
React.useEffect(() => { setVal(value || '') }, [value])
React.useEffect(() => { if (editing && inputRef.current) inputRef.current.focus() }, [editing])
const save = () => {
setEditing(false)
if (val !== (value || '')) onEdit(val)
}
return (
<div
onClick={() => setEditing(true)}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
cursor: 'pointer', minWidth: 0, flex: type === 'text' ? 1 : undefined,
}}
>
<Icon size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', lineHeight: 1 }}>{label}</div>
{editing ? (
<input
ref={inputRef}
type={type}
value={val}
onChange={e => setVal(e.target.value)}
onBlur={save}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setVal(value || ''); setEditing(false) } }}
onClick={e => e.stopPropagation()}
style={{
border: 'none', outline: 'none', background: 'none', padding: 0, margin: 0,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-primary)', fontFamily: 'inherit',
width: type === 'time' ? 50 : '100%', lineHeight: 1.3,
}}
/>
) : (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: value ? 'var(--text-primary)' : 'var(--text-faint)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{value || placeholder}
</div>
)}
</div>
</div>
)
}
function AccommodationList({ dayAccommodations, day, reservations, canEditDays, fmtTime, blurCodes, t, function AccommodationList({ dayAccommodations, day, reservations, canEditDays, fmtTime, blurCodes, t,
setAccommodation, setHotelForm, setHotelDayRange, setShowHotelPicker, handleRemoveAccommodation }: any) { setAccommodation, setHotelForm, setHotelDayRange, setShowHotelPicker, handleRemoveAccommodation }: any) {
return ( return (
@@ -3486,9 +3486,10 @@ describe('DayPlanSidebar', () => {
await user.click(screen.getByRole('button', { name: /confirm/i })) await user.click(screen.getByRole('button', { name: /confirm/i }))
await waitFor(() => expect(vi.mocked(assignmentsApi.updateTime)).toHaveBeenCalledWith(1, 12, { place_time: null, end_time: null })) await waitFor(() => expect(vi.mocked(assignmentsApi.updateTime)).toHaveBeenCalledWith(1, 12, { place_time: null, end_time: null }))
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, [11, 12])) await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, [11, 12]))
// The non-place rows keep their relative slot while the places are rewritten. // The stop really moves past the booking: the bus lands behind both places
// instead of keeping its old slot between them.
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalledWith( await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalledWith(
1, [{ id: 541, day_plan_position: expect.any(Number) }], 10, 1, [{ id: 541, day_plan_position: 1.5 }], 10,
)) ))
}) })
@@ -3706,6 +3707,16 @@ describe('DayPlanSidebar remaining branches', () => {
expect(row).not.toHaveTextContent('4B') expect(row).not.toHaveTextContent('4B')
}) })
it('FE-W5DPS-004: metadata that is not valid JSON leaves the row standing', async () => {
renderWith([buildReservation({
id: 604, type: 'bus', title: 'Night bus', day_id: 10,
reservation_time: '2025-06-01T09:00:00', metadata: '{not json',
}) as Reservation])
// The parse used to throw during render and took the whole sidebar with it.
expect(await screen.findByText('Night bus')).toBeInTheDocument()
})
it('FE-W5DPS-005: the place menu offers Save to collection only with the addon on', async () => { it('FE-W5DPS-005: the place menu offers Save to collection only with the addon on', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const place = buildPlace({ id: 1, name: 'Louvre' }) const place = buildPlace({ id: 1, name: 'Louvre' })
+92 -249
View File
@@ -6,7 +6,7 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 're
import { avatarSrc } from '../../utils/avatarSrc' import { avatarSrc } from '../../utils/avatarSrc'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react' import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client' import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator' import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog' import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
@@ -62,7 +62,9 @@ interface DayPlanSidebarProps {
onReorder: (dayId: number, orderedIds: number[]) => void onReorder: (dayId: number, orderedIds: number[]) => void
onReorderDays?: (orderedIds: number[]) => void onReorderDays?: (orderedIds: number[]) => void
onAddDay?: (position?: number) => void onAddDay?: (position?: number) => void
/** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */
onUpdateDayTitle: (dayId: number, title: string) => void onUpdateDayTitle: (dayId: number, title: string) => void
/** The day route is computed by the planner page itself; kept for the existing call sites. */
onRouteCalculated: (route: RouteResult | null) => void onRouteCalculated: (route: RouteResult | null) => void
onAssignToDay: (placeId: number, dayId: number, position?: number) => void onAssignToDay: (placeId: number, dayId: number, position?: number) => void
onRemoveAssignment: (dayId: number, assignmentId: number) => void onRemoveAssignment: (dayId: number, assignmentId: number) => void
@@ -121,7 +123,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
trip, days, places, categories, assignments, trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId, selectedAssignmentId, selectedDayId, selectedPlaceId, selectedAssignmentId,
onSelectDay, onPlaceClick, onDayDetail, accommodations = [], onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
onReorder, onReorderDays, onAddDay, onUpdateDayTitle, onRouteCalculated, onReorder, onReorderDays, onAddDay,
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [], reservations = [],
visibleConnectionIds = [], visibleConnectionIds = [],
@@ -175,10 +177,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
return new Set<number>(days.map(d => d.id)) return new Set<number>(days.map(d => d.id))
}) })
useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays]) useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays])
const [editingDayId, setEditingDayId] = useState(null)
const [editTitle, setEditTitle] = useState('')
const [isCalculating, setIsCalculating] = useState(false)
const [routeInfo, setRouteInfo] = useState(null)
// Per-segment legs keyed by day id, then by the start place's assignment id (or the // Per-segment legs keyed by day id, then by the start place's assignment id (or the
// transport's reservation id). Nested per day so several Route-toggled mobile days // transport's reservation id). Nested per day so several Route-toggled mobile days
// can't collide in one flat map — assignment ids and reservation ids come from // can't collide in one flat map — assignment ids and reservation ids come from
@@ -220,12 +218,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}, [externalTransportDetail, onExternalTransportDetailHandled]) }, [externalTransportDetail, onExternalTransportDetailHandled])
const [timeConfirm, setTimeConfirm] = useState<{ const [timeConfirm, setTimeConfirm] = useState<{
dayId: number; fromId: number; time: string; dayId: number; fromId: number; time: string;
// For drag & drop reorder // Target slot of the pending move — drag & drop and the arrow buttons both
// describe it as "put fromId before/after this merged row".
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null; fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null;
// For arrow reorder
reorderIds?: number[];
} | null>(null) } | null>(null)
const inputRef = useRef(null)
const dragDataRef = useRef(null) const dragDataRef = useRef(null)
const scrollContainerRef = useRef<HTMLDivElement | null>(null) const scrollContainerRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -289,10 +285,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
prevDayCount.current = days.length prevDayCount.current = days.length
}, [days.length, tripId]) }, [days.length, tripId])
useEffect(() => {
if (editingDayId && inputRef.current) inputRef.current.focus()
}, [editingDayId])
// Globaler Aufräum-Listener: wenn ein Drag endet ohne Drop, alles zurücksetzen // Globaler Aufräum-Listener: wenn ein Drag endet ohne Drop, alles zurücksetzen
useEffect(() => { useEffect(() => {
const cleanup = () => { const cleanup = () => {
@@ -582,41 +574,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}) })
} }
// Check if a proposed reorder of place IDs would break chronological order
// of ALL timed items (places with time + transport bookings)
const wouldBreakChronology = (dayId: number, newPlaceIds: number[]) => {
const da = getDayAssignments(dayId)
const transport = getTransportForDay(dayId)
// Simulate the merged list with places in new order + transports at their positions
// Places get sequential integer positions
const simItems: { pos: number; minutes: number }[] = []
newPlaceIds.forEach((id, idx) => {
const a = da.find(x => x.id === id)
const m = parseTimeToMinutes(a?.place?.place_time)
if (m !== null) simItems.push({ pos: idx, minutes: m })
})
// Transports: compute where they'd go with the new place order
for (const r of transport) {
const rMin = parseTimeToMinutes(r.reservation_time)
if (rMin === null) continue
// Find the last place (in new order) with time <= transport time
let afterIdx = -1
newPlaceIds.forEach((id, idx) => {
const a = da.find(x => x.id === id)
const pm = parseTimeToMinutes(a?.place?.place_time)
if (pm !== null && pm <= rMin) afterIdx = idx
})
const pos = afterIdx >= 0 ? afterIdx + 0.5 : newPlaceIds.length + 0.5
simItems.push({ pos, minutes: rMin })
}
// Sort by position and check chronological order
simItems.sort((a, b) => a.pos - b.pos)
return !simItems.every((item, i) => i === 0 || item.minutes >= simItems[i - 1].minutes)
}
const openEditNote = (dayId: number, note: DayNote, e?: React.MouseEvent) => { const openEditNote = (dayId: number, note: DayNote, e?: React.MouseEvent) => {
e?.stopPropagation() e?.stopPropagation()
_openEditNote(dayId, note) _openEditNote(dayId, note)
@@ -793,7 +750,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const confirmTimeRemoval = async () => { const confirmTimeRemoval = async () => {
if (!timeConfirm) return if (!timeConfirm) return
const saved = { ...timeConfirm } const saved = { ...timeConfirm }
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter, toLegIndex } = saved const { dayId, fromId, fromType, toType, toId, insertAfter, toLegIndex } = saved
setTimeConfirm(null) setTimeConfirm(null)
// Remove time from assignment // Remove time from assignment
@@ -812,29 +769,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
return return
} }
// Build new merged order from either arrow reorderIds or drag & drop params // Re-apply the pending move against the current merged list
const m = getMergedItems(dayId) const m = getMergedItems(dayId)
if (reorderIds) {
// Arrow reorder: rebuild merged list with places in the new order,
// keeping transports and notes at their relative positions
const newMerged: typeof m = []
let rIdx = 0
for (const item of m) {
if (item.type === 'place') {
// Replace with the place from reorderIds at this position
const nextId = reorderIds[rIdx++]
const replacement = m.find(i => i.type === 'place' && i.data.id === nextId)
if (replacement) newMerged.push(replacement)
} else {
newMerged.push(item)
}
}
await applyMergedOrder(dayId, newMerged)
return
}
// Drag & drop reorder
if (fromType && toType) { if (fromType && toType) {
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex) const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
@@ -856,33 +793,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
await _moveNote(dayId, noteId, direction, getMergedItems) await _moveNote(dayId, noteId, direction, getMergedItems)
} }
const startEditTitle = (day, e) => {
e.stopPropagation()
setEditTitle(day.title || '')
setEditingDayId(day.id)
}
const saveTitle = async (dayId) => {
setEditingDayId(null)
await onUpdateDayTitle?.(dayId, editTitle.trim())
}
const handleCalculateRoute = async () => {
if (!selectedDayId) return
const da = getDayAssignments(selectedDayId)
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng).map(p => ({ lat: p.lat, lng: p.lng }))
if (waypoints.length < 2) { toast.error(t('dayplan.toast.needTwoPlaces')); return }
setIsCalculating(true)
try {
const result = await calculateRoute(waypoints, 'walking')
// Luftlinien zwischen Wegpunkten anzeigen
const lineCoords = waypoints.map(p => [p.lat, p.lng] as [number, number])
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.({ ...result, coordinates: lineCoords })
} catch { toast.error(t('dayplan.toast.routeError')) }
finally { setIsCalculating(false) }
}
const toggleLock = (assignmentId) => { const toggleLock = (assignmentId) => {
const prevLocked = new Set(lockedIds) const prevLocked = new Set(lockedIds)
setLockedIds(prev => { setLockedIds(prev => {
@@ -894,8 +804,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) }) pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
} }
const handleOptimize = async (dayId: number | null = selectedDayId) => { const handleOptimize = async (dayId: number) => {
if (!dayId) return
const da = getDayAssignments(dayId) const da = getDayAssignments(dayId)
if (da.length < 3) return if (da.length < 3) return
@@ -975,27 +884,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
window.__dragData = null window.__dragData = null
} }
const handleDropOnRow = (e, dayId, toIdx) => {
e.preventDefault()
e.stopPropagation()
setDragOverDayId(null)
const placeId = e.dataTransfer.getData('placeId')
const fromAssignmentId = e.dataTransfer.getData('assignmentId')
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
} else if (fromAssignmentId) {
const da = getDayAssignments(dayId)
const fromIdx = da.findIndex(a => String(a.id) === fromAssignmentId)
if (fromIdx === -1 || fromIdx === toIdx) { setDraggingId(null); dragDataRef.current = null; return }
const ids = da.map(a => a.id)
const [removed] = ids.splice(fromIdx, 1)
ids.splice(toIdx, 0, removed)
onReorder(dayId, ids)
}
setDraggingId(null)
}
const totalCostLabel = useMemo(() => { const totalCostLabel = useMemo(() => {
const entries = days.flatMap(d => (assignments[String(d.id)] || []).map(a => ({ const entries = days.flatMap(d => (assignments[String(d.id)] || []).map(a => ({
amount: Number(a.place?.price) || 0, amount: Number(a.place?.price) || 0,
@@ -1025,8 +913,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onReorder, onReorder,
onReorderDays, onReorderDays,
onAddDay, onAddDay,
onUpdateDayTitle,
onRouteCalculated,
onAssignToDay, onAssignToDay,
onRemoveAssignment, onRemoveAssignment,
onEditPlace, onEditPlace,
@@ -1088,14 +974,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
moveNote, moveNote,
expandedDays, expandedDays,
setExpandedDays, setExpandedDays,
editingDayId,
setEditingDayId,
editTitle,
setEditTitle,
isCalculating,
setIsCalculating,
routeInfo,
setRouteInfo,
routeLegs, routeLegs,
setRouteLegs, setRouteLegs,
hotelLegs, hotelLegs,
@@ -1127,7 +1005,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
setTransportPosVersion, setTransportPosVersion,
timeConfirm, timeConfirm,
setTimeConfirm, setTimeConfirm,
inputRef,
dragDataRef, dragDataRef,
scrollContainerRef, scrollContainerRef,
initedTransportIds, initedTransportIds,
@@ -1148,17 +1025,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
initTransportPositions, initTransportPositions,
getMergedItems, getMergedItems,
mergedItemsMap, mergedItemsMap,
wouldBreakChronology,
applyMergedOrder, applyMergedOrder,
handleMergedDrop, handleMergedDrop,
confirmTimeRemoval, confirmTimeRemoval,
startEditTitle,
saveTitle,
handleCalculateRoute,
toggleLock, toggleLock,
handleOptimize, handleOptimize,
handleDropOnDay, handleDropOnDay,
handleDropOnRow,
totalCostLabel, totalCostLabel,
anyGeoAssignment, anyGeoAssignment,
anyGeoPlace, anyGeoPlace,
@@ -1212,8 +1084,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onReorder, onReorder,
onReorderDays, onReorderDays,
onAddDay, onAddDay,
onUpdateDayTitle,
onRouteCalculated,
onAssignToDay, onAssignToDay,
onRemoveAssignment, onRemoveAssignment,
onEditPlace, onEditPlace,
@@ -1275,14 +1145,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
moveNote, moveNote,
expandedDays, expandedDays,
setExpandedDays, setExpandedDays,
editingDayId,
setEditingDayId,
editTitle,
setEditTitle,
isCalculating,
setIsCalculating,
routeInfo,
setRouteInfo,
routeLegs, routeLegs,
setRouteLegs, setRouteLegs,
hotelLegs, hotelLegs,
@@ -1314,7 +1176,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
setTransportPosVersion, setTransportPosVersion,
timeConfirm, timeConfirm,
setTimeConfirm, setTimeConfirm,
inputRef,
dragDataRef, dragDataRef,
scrollContainerRef, scrollContainerRef,
initedTransportIds, initedTransportIds,
@@ -1335,17 +1196,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
initTransportPositions, initTransportPositions,
getMergedItems, getMergedItems,
mergedItemsMap, mergedItemsMap,
wouldBreakChronology,
applyMergedOrder, applyMergedOrder,
handleMergedDrop, handleMergedDrop,
confirmTimeRemoval, confirmTimeRemoval,
startEditTitle,
saveTitle,
handleCalculateRoute,
toggleLock, toggleLock,
handleOptimize, handleOptimize,
handleDropOnDay, handleDropOnDay,
handleDropOnRow,
totalCostLabel, totalCostLabel,
anyGeoAssignment, anyGeoAssignment,
anyGeoPlace, anyGeoPlace,
@@ -1530,83 +1386,65 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
})()} })()}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{editingDayId === day.id ? ( <div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
<input <span style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
ref={inputRef} {day.title || t('dayplan.dayN', { n: index + 1 })}
value={editTitle} </span>
onChange={e => setEditTitle(e.target.value)} {formattedDate && (
onBlur={() => saveTitle(day.id)} <>
onKeyDown={e => { if (e.key === 'Enter') saveTitle(day.id); if (e.key === 'Escape') setEditingDayId(null) }} <span style={{ flexShrink: 0, width: 1, height: 11, background: 'var(--border-primary)' }} />
onClick={e => e.stopPropagation()} <span style={{ flexShrink: 0, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>
style={{ {formattedDate}
width: '100%', border: 'none', outline: 'none', </span>
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', </>
background: 'transparent', padding: 0, fontFamily: 'inherit', )}
borderBottom: '1.5px solid var(--text-primary)', </div>
}} {(() => {
/> const hasAccs = accommodations.some(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
) : (<> const hasRentals = getActiveRentalsForDay(day.id).length > 0
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}> if (!hasAccs && !hasRentals) return null
<span style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}> return <div style={{ height: 1, background: 'var(--border-faint)', margin: '5px 0 5px' }} />
{day.title || t('dayplan.dayN', { n: index + 1 })} })()}
</span> <div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'nowrap', minWidth: 0 }}>
{formattedDate && (
<>
<span style={{ flexShrink: 0, width: 1, height: 11, background: 'var(--border-primary)' }} />
<span style={{ flexShrink: 0, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 400, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>
{formattedDate}
</span>
</>
)}
</div>
{(() => { {(() => {
const hasAccs = accommodations.some(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
const hasRentals = getActiveRentalsForDay(day.id).length > 0 // Sort: check-out first, then ongoing stays, then check-in last
if (!hasAccs && !hasRentals) return null .sort((a, b) => {
return <div style={{ height: 1, background: 'var(--border-faint)', margin: '5px 0 5px' }} /> const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
})()} const bIsOut = b.end_day_id === day.id && b.start_day_id !== day.id
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'nowrap', minWidth: 0 }}> const aIsIn = a.start_day_id === day.id
{(() => { const bIsIn = b.start_day_id === day.id
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) if (aIsOut && !bIsOut) return -1
// Sort: check-out first, then ongoing stays, then check-in last if (!aIsOut && bIsOut) return 1
.sort((a, b) => { if (aIsIn && !bIsIn) return 1
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id if (!aIsIn && bIsIn) return -1
const bIsOut = b.end_day_id === day.id && b.start_day_id !== day.id return 0
const aIsIn = a.start_day_id === day.id
const bIsIn = b.start_day_id === day.id
if (aIsOut && !bIsOut) return -1
if (!aIsOut && bIsOut) return 1
if (aIsIn && !bIsIn) return 1
if (!aIsIn && bIsIn) return -1
return 0
})
if (dayAccs.length === 0) return null
return dayAccs.map(acc => {
const isCheckIn = acc.start_day_id === day.id
const isCheckOut = acc.end_day_id === day.id
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)'
return (
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: (acc as any).place_id ? 'pointer' : 'default', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
<Hotel size={11} strokeWidth={1.8} style={{ color: iconColor, flexShrink: 0 }} />
<span className="text-content-muted" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
</span>
)
}) })
})()} if (dayAccs.length === 0) return null
{/* Active rental car badges */} return dayAccs.map(acc => {
{(() => { const isCheckIn = acc.start_day_id === day.id
const activeRentals = getActiveRentalsForDay(day.id) const isCheckOut = acc.end_day_id === day.id
if (activeRentals.length === 0) return null const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)'
return activeRentals.map(r => ( return (
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', borderRadius: 7, padding: '2px 7px 2px 6px' }}> <span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: (acc as any).place_id ? 'pointer' : 'default', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
<Car size={11} strokeWidth={1.8} className="text-content-faint" style={{ flexShrink: 0 }} /> <Hotel size={11} strokeWidth={1.8} style={{ color: iconColor, flexShrink: 0 }} />
<span className="text-content-muted" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span> <span className="text-content-muted" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
</span> </span>
)) )
})()} })
</div> })()}
</> {/* Active rental car badges */}
)} {(() => {
const activeRentals = getActiveRentalsForDay(day.id)
if (activeRentals.length === 0) return null
return activeRentals.map(r => (
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
<Car size={11} strokeWidth={1.8} className="text-content-faint" style={{ flexShrink: 0 }} />
<span className="text-content-muted" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
</span>
))
})()}
</div>
{cost && ( {cost && (
<div style={{ marginTop: 2 }}> <div style={{ marginTop: 2 }}>
<span className="text-[#059669]" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{cost}</span> <span className="text-[#059669]" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{cost}</span>
@@ -1767,8 +1605,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const isChronological = timedInNewOrder.every((t, i) => i === 0 || t >= timedInNewOrder[i - 1]) const isChronological = timedInNewOrder.every((t, i) => i === 0 || t >= timedInNewOrder[i - 1])
if (!isChronological) { if (!isChronological) {
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
// Store the new merged order for confirm action // Describe the swap by its neighbour so the confirm step can
setTimeConfirm({ dayId: day.id, fromId: assignment.id, time: timeStr, reorderIds: newOrder.filter(i => i.type === 'place').map(i => i.data.id) }) // replay it against the merged list — a place-id projection
// would drop moves across a booking or note.
const neighbour = m[targetIdx]
setTimeConfirm({
dayId: day.id, fromId: assignment.id, time: timeStr,
fromType: 'place', toType: neighbour.type, toId: neighbour.data.id,
insertAfter: direction === 'down',
toLegIndex: neighbour.data?.__leg?.index ?? null,
})
return return
} }
} }
@@ -1959,7 +1805,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
) )
})()} })()}
{(() => { {(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) let meta: any = {}
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
if (!meta) return null if (!meta) return null
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span> if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span> if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
@@ -2088,7 +1935,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const TransportIcon = RES_ICONS[res.type] || Ticket const TransportIcon = RES_ICONS[res.type] || Ticket
const color = '#3b82f6' const color = '#3b82f6'
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) let meta: any = {}
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
// Subtitle aus Metadaten zusammensetzen // Subtitle aus Metadaten zusammensetzen
let subtitle = '' let subtitle = ''
@@ -2473,10 +2321,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
next.has(day.id) ? next.delete(day.id) : next.add(day.id) next.has(day.id) ? next.delete(day.id) : next.add(day.id)
return next return next
}) })
} else if (isSelected) { onToggleRoute?.() } } else {
// Desktop: the route is computed for the globally selected day, // Desktop: the tools only render for the selected day, so the
// so tapping Route on another day first points the selection here. // toggle always applies to it.
else { onSelectDay(day.id, true); if (!routeShown) onToggleRoute?.() } onToggleRoute?.()
}
}} }}
className={routeActive ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'} className={routeActive ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
style={{ style={{
@@ -2558,22 +2407,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
})} })}
</div> </div>
</div> </div>
{isSelected && (routeInfo || daySchedule.minutesByDay[day.id]) && ( {/* Time plugins contributed to this day (charging, buffers) — the
dayScheduleProvider minutes folded into the footer total. */}
{isSelected && daySchedule.minutesByDay[day.id] ? (
<div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 8, padding: '5px 10px' }}> <div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 8, padding: '5px 10px' }}>
{routeInfo && <span>{routeInfo.distance}</span>} <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{routeInfo && <span className="text-content-faint">·</span>} <Zap size={11} strokeWidth={2} />
{routeInfo && <span>{routeInfo.duration}</span>} +{formatScheduleMinutes(daySchedule.minutesByDay[day.id])}
{/* Time plugins contributed to this day (charging, buffers) — the </span>
dayScheduleProvider minutes folded into the footer total. */}
{daySchedule.minutesByDay[day.id] ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{routeInfo && <span className="text-content-faint">·</span>}
<Zap size={11} strokeWidth={2} />
+{formatScheduleMinutes(daySchedule.minutesByDay[day.id])}
</span>
) : null}
</div> </div>
)} ) : null}
</div> </div>
)} )}
@@ -116,10 +116,10 @@ describe('LocationSelect', () => {
render(<Host onPick={onPick} />); render(<Host onPick={onPick} />);
await user.type(screen.getByRole('textbox'), 'Oberkampf'); await user.type(screen.getByRole('textbox'), 'Oberkampf');
// The row falls back to the address for its title and still prints the // The row falls back to the address for its title and skips the address
// address line underneath, so the same text shows up twice. // subtitle, so the text shows up once.
const rows = await screen.findAllByText('12 Rue Oberkampf'); const rows = await screen.findAllByText('12 Rue Oberkampf');
expect(rows).toHaveLength(2); expect(rows).toHaveLength(1);
await user.click(rows[0]); await user.click(rows[0]);
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: '12 Rue Oberkampf' })); expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: '12 Rue Oberkampf' }));
@@ -128,7 +128,7 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
<MapPin size={12} className="text-content-faint" style={{ marginTop: 2, flexShrink: 0 }} /> <MapPin size={12} className="text-content-faint" style={{ marginTop: 2, flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0 }}> <span style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name || r.address}</div> <div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name || r.address}</div>
{r.address && r.name !== r.address && ( {r.address && r.name && r.name !== r.address && (
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.address}</div> <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.address}</div>
)} )}
</span> </span>
@@ -379,14 +379,35 @@ describe('PlaceFormModal', () => {
}); });
it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => { it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => {
const user = userEvent.setup();
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' }); const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
// Directly invoke handleCreateCategory by setting showNewCategory via the category name input
// Since there's no UI trigger for showNewCategory, we test that the prop is accepted
// and category creation works by checking the modal renders correctly
render(<PlaceFormModal {...defaultProps} onCategoryCreated={onCategoryCreated} />); render(<PlaceFormModal {...defaultProps} onCategoryCreated={onCategoryCreated} />);
expect(screen.getByText('Category')).toBeInTheDocument();
// onCategoryCreated not called unless the new-category form is shown and submitted
expect(onCategoryCreated).not.toHaveBeenCalled(); expect(onCategoryCreated).not.toHaveBeenCalled();
// The plus next to the category select swaps in the inline name field.
await user.click(screen.getByRole('button', { name: 'New category' }));
await user.type(screen.getByPlaceholderText('Category name'), 'Beaches');
await user.click(screen.getByRole('button', { name: 'OK' }));
expect(onCategoryCreated).toHaveBeenCalledWith({ name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
// The new category is selected and the inline field closes again.
await waitFor(() => expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument());
});
it('FE-PLANNER-PLACEFORM-024b: an empty category name does not call onCategoryCreated', async () => {
const user = userEvent.setup();
const onCategoryCreated = vi.fn();
render(<PlaceFormModal {...defaultProps} onCategoryCreated={onCategoryCreated} />);
await user.click(screen.getByRole('button', { name: 'New category' }));
await user.click(screen.getByRole('button', { name: 'OK' }));
expect(onCategoryCreated).not.toHaveBeenCalled();
const inlineRow = screen.getByPlaceholderText('Category name').parentElement!;
await user.click(within(inlineRow).getByRole('button', { name: 'Cancel' }));
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
}); });
// ── Time section (edit mode only) ──────────────────────────────────────────── // ── Time section (edit mode only) ────────────────────────────────────────────
@@ -8,7 +8,7 @@ import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import CollectionPicker from '../Collections/CollectionPicker' import CollectionPicker from '../Collections/CollectionPicker'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Search, Paperclip, X, AlertTriangle, Loader2 } from 'lucide-react' import { Search, Paperclip, X, AlertTriangle, Loader2, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isGoogleMapsUrl, type PlaceFormData } from './PlaceFormModal.helpers' import { DEFAULT_FORM, isGoogleMapsUrl, type PlaceFormData } from './PlaceFormModal.helpers'
@@ -747,6 +747,15 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
style={{ flex: 1 }} style={{ flex: 1 }}
size="sm" size="sm"
/> />
<button
type="button"
onClick={() => setShowNewCategory(true)}
aria-label={t('places.newCategory')}
title={t('places.newCategory')}
className="text-gray-500 px-2 hover:text-gray-700"
>
<Plus size={16} />
</button>
</div> </div>
) : ( ) : (
<div className="flex gap-2"> <div className="flex gap-2">
@@ -497,20 +497,6 @@ function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hove
) )
} }
interface RowProps {
icon: React.ReactNode
children: React.ReactNode
}
function Row({ icon, children }: RowProps) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flexShrink: 0 }}>{icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
</div>
)
}
interface ActionButtonProps { interface ActionButtonProps {
onClick: () => void onClick: () => void
variant: 'primary' | 'ghost' | 'danger' variant: 'primary' | 'ghost' | 'danger'
@@ -214,19 +214,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId, days, places, accommodations]) }, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId, days, places, accommodations])
// 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)
useEffect(() => {
if (!isOpen || !reservation || reservation.type !== 'hotel' || !reservation.accommodation_id) return
const acc = accommodations.find(a => a.id == reservation.accommodation_id)
if (!acc) return
setForm(prev => {
if (prev.hotel_place_id !== '' || prev.hotel_start_day !== '' || prev.hotel_end_day !== '') return prev
const accPlace = places.find(p => p.id == acc.place_id)
return { ...prev, hotel_place_id: acc.place_id, hotel_start_day: acc.start_day_id, hotel_end_day: acc.end_day_id, hotel_address: accPlace?.address || prev.hotel_address }
})
}, [accommodations, isOpen, reservation, places])
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value })) const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const toggleTraveler = (id: number) => setTravelerIds(prev => { const toggleTraveler = (id: number) => setTravelerIds(prev => {
@@ -807,7 +794,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
} }
function formatDate(dateStr, locale) { function formatDate(dateStr, locale) {
if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00Z') const d = new Date(dateStr + 'T00:00:00Z')
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' }) return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
} }
@@ -90,7 +90,6 @@ interface ReservationCardProps {
} }
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [], contributions = [], detailPlugins = [] }: ReservationCardProps) { function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [], contributions = [], detailPlugins = [] }: ReservationCardProps) {
const { toggleReservationStatus } = useTripStore()
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
@@ -117,10 +116,6 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const handleToggle = async () => {
try { await toggleReservationStatus(tripId, r.id) }
catch { toast.error(t('reservations.toast.updateError')) }
}
const handleDelete = async () => { const handleDelete = async () => {
setShowDeleteConfirm(false) setShowDeleteConfirm(false)
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) } try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
@@ -456,7 +456,7 @@ describe('TransitSearchPanel', () => {
}) })
const mixed = { const mixed = {
...ITINERARY, ...ITINERARY,
legs: [leg('BUS', 'B1'), leg('TRAM', 'M4'), leg('FERRY', 'F10'), leg('FUNICULAR', 'FU'), leg('AERIAL_LIFT', 'AL'), leg('HIGHSPEED_RAIL', 'ICE 599')], legs: [leg('BUS', 'B1'), leg('TRAM', 'M4'), leg('FERRY', 'F10'), leg('FUNICULAR', 'FU'), leg('AERIAL_LIFT', 'AL'), leg('SUBWAY', 'U2'), leg('HIGHSPEED_RAIL', 'ICE 599')],
} }
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [mixed] }) transitApiMock.plan.mockResolvedValueOnce({ itineraries: [mixed] })
render(<TransitSearchPanel {...makeProps()} />) render(<TransitSearchPanel {...makeProps()} />)
@@ -469,9 +469,11 @@ describe('TransitSearchPanel', () => {
expect(icon('F10')).toContain('lucide-sailboat') expect(icon('F10')).toContain('lucide-sailboat')
expect(icon('FU')).toContain('lucide-cable-car') expect(icon('FU')).toContain('lucide-cable-car')
expect(icon('AL')).toContain('lucide-cable-car') expect(icon('AL')).toContain('lucide-cable-car')
// Anything else (rail and friends) falls through to lucide's `Train`, which expect(icon('U2')).toContain('lucide-train-front-tunnel')
// is an alias of TramFront — so rail and tram currently share a glyph. // Anything else (rail and friends) falls through to TrainFront, which stays
expect(icon('ICE 599')).toContain('lucide-tram-front') // distinct from the tram glyph.
expect(icon('ICE 599')).toContain('lucide-train-front')
expect(icon('ICE 599')).not.toContain('tunnel')
}) })
it('FE-PLANNER-TRANSIT-026: a leg without a usable timestamp renders an empty time cell, not "Invalid Date"', async () => { it('FE-PLANNER-TRANSIT-026: a leg without a usable timestamp renders an empty time cell, not "Invalid Date"', async () => {
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import tzlookup from 'tz-lookup' import tzlookup from 'tz-lookup'
import { ArrowLeftRight, ArrowRight, Bus, CableCar, ChevronDown, ChevronUp, Clock, Footprints, MapPin, Sailboat, Search, Train, TramFront, TrainFront } from 'lucide-react' import { ArrowLeftRight, ArrowRight, Bus, CableCar, ChevronDown, ChevronUp, Clock, Footprints, MapPin, Sailboat, Search, TramFront, TrainFront, TrainFrontTunnel } from 'lucide-react'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import { TransitMetaBadges } from './transitDisplay' import { TransitMetaBadges } from './transitDisplay'
import { transitApi } from '../../api/client' import { transitApi } from '../../api/client'
@@ -39,22 +39,24 @@ export interface PickedPlace { name: string; lat: number; lng: number }
// ── helpers ────────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────────
const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [ const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [
{ key: 'rail', labelKey: 'transit.mode.rail', Icon: Train, modes: 'HIGHSPEED_RAIL,LONG_DISTANCE,NIGHT_RAIL,REGIONAL_RAIL,SUBURBAN' }, // lucide's `Train` is an alias of TramFront, so rail keeps TrainFront and the
{ key: 'subway', labelKey: 'transit.mode.subway', Icon: TrainFront, modes: 'SUBWAY' }, // subway takes the tunnel variant — otherwise the chips share a glyph.
{ key: 'rail', labelKey: 'transit.mode.rail', Icon: TrainFront, modes: 'HIGHSPEED_RAIL,LONG_DISTANCE,NIGHT_RAIL,REGIONAL_RAIL,SUBURBAN' },
{ key: 'subway', labelKey: 'transit.mode.subway', Icon: TrainFrontTunnel, modes: 'SUBWAY' },
{ key: 'tram', labelKey: 'transit.mode.tram', Icon: TramFront, modes: 'TRAM' }, { key: 'tram', labelKey: 'transit.mode.tram', Icon: TramFront, modes: 'TRAM' },
{ key: 'bus', labelKey: 'transit.mode.bus', Icon: Bus, modes: 'BUS,COACH' }, { key: 'bus', labelKey: 'transit.mode.bus', Icon: Bus, modes: 'BUS,COACH' },
{ key: 'ferry', labelKey: 'transit.mode.ferry', Icon: Sailboat, modes: 'FERRY' }, { key: 'ferry', labelKey: 'transit.mode.ferry', Icon: Sailboat, modes: 'FERRY' },
{ key: 'cable', labelKey: 'transit.mode.cable', Icon: CableCar, modes: 'FUNICULAR,AERIAL_LIFT' }, { key: 'cable', labelKey: 'transit.mode.cable', Icon: CableCar, modes: 'FUNICULAR,AERIAL_LIFT' },
] ]
// Only called for non-WALK legs — walking renders its own Footprints inline.
function legIcon(mode: string) { function legIcon(mode: string) {
if (mode === 'WALK') return Footprints
if (mode === 'BUS' || mode === 'COACH') return Bus if (mode === 'BUS' || mode === 'COACH') return Bus
if (mode === 'TRAM') return TramFront if (mode === 'TRAM') return TramFront
if (mode === 'SUBWAY') return TrainFront if (mode === 'SUBWAY') return TrainFrontTunnel
if (mode === 'FERRY') return Sailboat if (mode === 'FERRY') return Sailboat
if (mode === 'FUNICULAR' || mode === 'AERIAL_LIFT') return CableCar if (mode === 'FUNICULAR' || mode === 'AERIAL_LIFT') return CableCar
return Train return TrainFront
} }
function tzAt(lat: number, lng: number): string { function tzAt(lat: number, lng: number): string {
@@ -58,7 +58,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections')) const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'))
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it. // Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
const canEnrichImport = useAuthStore((s) => s.hasMapsKey) const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
const isNaverListImportEnabled = true
const [fileImportOpen, setFileImportOpen] = useState(false) const [fileImportOpen, setFileImportOpen] = useState(false)
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null) const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
@@ -106,19 +105,13 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [listImportLoading, setListImportLoading] = useState(false) const [listImportLoading, setListImportLoading] = useState(false)
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google') const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
const [listImportEnrich, setListImportEnrich] = useState(false) const [listImportEnrich, setListImportEnrich] = useState(false)
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google'] const availableListImportProviders: Array<'google' | 'naver'> = ['google', 'naver']
const hasMultipleListImportProviders = availableListImportProviders.length > 1 const hasMultipleListImportProviders = availableListImportProviders.length > 1
useEffect(() => {
if (!isNaverListImportEnabled && listImportProvider === 'naver') {
setListImportProvider('google')
}
}, [isNaverListImportEnabled, listImportProvider])
const handleListImport = async () => { const handleListImport = async () => {
if (!listImportUrl.trim()) return if (!listImportUrl.trim()) return
setListImportLoading(true) setListImportLoading(true)
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google' const provider = listImportProvider
try { try {
const enrich = listImportEnrich && canEnrichImport const enrich = listImportEnrich && canEnrichImport
const result = provider === 'google' const result = provider === 'google'
@@ -186,7 +179,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
if (next.has(catId)) next.delete(catId); else next.add(catId) if (next.has(catId)) next.delete(catId); else next.add(catId)
setCategoryFilters(next) setCategoryFilters(next)
} }
const [dayPickerPlace, setDayPickerPlace] = useState(null) const [dayPickerPlace, setDayPickerPlace] = useState<Place | null>(null)
const [catDropOpen, setCatDropOpen] = useState(false) const [catDropOpen, setCatDropOpen] = useState(false)
const [mobileShowDays, setMobileShowDays] = useState(false) const [mobileShowDays, setMobileShowDays] = useState(false)
@@ -4,6 +4,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { escapeHtml } from '@trek/shared'
import { authApi, adminApi } from '../../api/client' import { authApi, adminApi } from '../../api/client'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../types'
import type { UserWithOidc } from '../../types' import type { UserWithOidc } from '../../types'
@@ -106,7 +107,7 @@ export default function AccountTab(): React.ReactElement {
if (!backupCodesText) return if (!backupCodesText) return
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title> const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style> <style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
</head><body><h1>TREK MFA Backup Codes</h1><p>${new Date().toLocaleString()}</p><pre>${backupCodesText}</pre></body></html>` </head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(new Date().toLocaleString())}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`
const w = window.open('', '_blank', 'width=900,height=700') const w = window.open('', '_blank', 'width=900,height=700')
if (!w) return if (!w) return
w.document.open() w.document.open()
@@ -196,7 +196,6 @@ export default function MapSettingsTab(): React.ReactElement {
setSaving(true) setSaving(true)
try { try {
const glStyle = provider === 'leaflet' ? mapboxStyle : normalizeStyleForProvider(provider, mapboxStyle) 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. // 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 } const stylePatch = provider === 'maplibre-gl' ? { maplibre_style: glStyle } : { mapbox_style: glStyle }
await updateSettings({ await updateSettings({
@@ -207,6 +206,8 @@ export default function MapSettingsTab(): React.ReactElement {
mapbox_3d_enabled: mapbox3d, mapbox_3d_enabled: mapbox3d,
mapbox_quality_mode: mapboxQuality, mapbox_quality_mode: mapboxQuality,
}) })
// Only mirror the normalized style into the form once it is actually persisted.
setMapboxStyle(glStyle)
toast.success(t('settings.toast.mapSaved')) toast.success(t('settings.toast.mapSaved'))
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error')) toast.error(err instanceof Error ? err.message : t('common.error'))
@@ -165,9 +165,9 @@ export default function NotificationsTab(): React.ReactElement {
await settingsApi.setBulk({ await settingsApi.setBulk({
ntfy_topic: ntfyTopic, ntfy_topic: ntfyTopic,
ntfy_server: ntfyServer, ntfy_server: ntfyServer,
...(ntfyToken && ntfyToken !== '••••••••' ? { ntfy_token: ntfyToken } : {}), ...(ntfyToken ? { ntfy_token: ntfyToken } : {}),
}) })
if (ntfyToken && ntfyToken !== '••••••••') setNtfyTokenIsSet(true) if (ntfyToken) setNtfyTokenIsSet(true)
toast.success(t('settings.ntfyUrl.saved')) toast.success(t('settings.ntfyUrl.saved'))
} catch { } catch {
toast.error(t('common.error')) toast.error(t('common.error'))
@@ -194,7 +194,9 @@ export default function NotificationsTab(): React.ReactElement {
const result = await notificationsApi.testNtfy({ const result = await notificationsApi.testNtfy({
topic: ntfyTopic, topic: ntfyTopic,
server: ntfyServer || null, server: ntfyServer || null,
token: ntfyToken && ntfyToken !== '••••••••' ? ntfyToken : null, // A stored token is only masked in the placeholder, never in state — sending
// null makes the server fall back to the saved one.
token: ntfyToken || null,
}) })
if (result.success) toast.success(t('settings.ntfyUrl.testSuccess')) if (result.success) toast.success(t('settings.ntfyUrl.testSuccess'))
else toast.error(result.error || t('settings.ntfyUrl.testFailed')) else toast.error(result.error || t('settings.ntfyUrl.testFailed'))
@@ -63,8 +63,9 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
const canAdd = enabled && configured const canAdd = enabled && configured
// Both step-up flows are gated by disabled={busy || !pwd} on their submit button,
// so the password is always present by the time these run.
const handleAdd = async () => { const handleAdd = async () => {
if (!addPwd) { toast.error(t('settings.passkey.passwordRequired')); return }
setBusy(true) setBusy(true)
try { try {
const options = await authApi.passkey.registerOptions(addPwd) const options = await authApi.passkey.registerOptions(addPwd)
@@ -94,7 +95,6 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
} }
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!deletePwd) { toast.error(t('settings.passkey.passwordRequired')); return }
setBusy(true) setBusy(true)
try { try {
await authApi.passkey.delete(id, deletePwd) await authApi.passkey.delete(id, deletePwd)
@@ -192,8 +192,11 @@ export default function PhotoProvidersSection(): React.ReactElement {
if (!testPath) return if (!testPath) return
setProviderTesting(prev => ({ ...prev, [provider.id]: true })) setProviderTesting(prev => ({ ...prev, [provider.id]: true }))
try { try {
const payload = buildProviderPayload(provider) // Only a POST probe carries the form values. A provider that declares just a
const res = cfg.test_post ? await apiClient.post(testPath, payload) : await apiClient.get(testPath) // GET (test_get / status_get) is probed against its saved credentials.
const res = cfg.test_post
? await apiClient.post(testPath, buildProviderPayload(provider))
: await apiClient.get(testPath)
const ok = !!res.data?.connected const ok = !!res.data?.connected
setProviderConnected(prev => ({ ...prev, [provider.id]: ok })) setProviderConnected(prev => ({ ...prev, [provider.id]: ok }))
if (ok) { if (ok) {
@@ -1,10 +1,9 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Info, AlertTriangle, AlertOctagon, X } from 'lucide-react'; import { Info, AlertTriangle, AlertOctagon, X } from 'lucide-react';
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js'; import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js'; import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
import { useTranslation } from '../../i18n/index.js'; import { useTranslation } from '../../i18n/index.js';
import { isRtlLanguage } from '../../i18n/index.js';
import { runNoticeAction } from './noticeActions.js'; import { runNoticeAction } from './noticeActions.js';
const SEVERITY_ICONS: Record<string, React.ElementType> = { const SEVERITY_ICONS: Record<string, React.ElementType> = {
@@ -43,39 +42,37 @@ const SEVERITY = {
interface BannerItemProps { interface BannerItemProps {
notice: SystemNoticeDTO; notice: SystemNoticeDTO;
onDismiss: () => void; onDismiss: () => void;
language: string;
} }
function CTALink({ function CTALink({
notice, notice,
cta,
label, label,
onDismiss, onDismiss,
}: { }: {
notice: SystemNoticeDTO; notice: SystemNoticeDTO;
cta: NonNullable<SystemNoticeDTO['cta']>;
label: string; label: string;
onDismiss: () => void; onDismiss: () => void;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
function handleClick() { function handleClick() {
if (!notice.cta) return; if (cta.kind === 'nav') {
if (notice.cta.kind === 'nav') { navigate(cta.href);
navigate(notice.cta.href);
if (notice.dismissible) onDismiss(); if (notice.dismissible) onDismiss();
} else if (notice.cta.kind === 'link') { } else if (cta.kind === 'link') {
window.open(notice.cta.href, '_blank', 'noopener,noreferrer'); window.open(cta.href, '_blank', 'noopener,noreferrer');
} else { } else {
runNoticeAction(notice.cta.actionId, { navigate }); runNoticeAction(cta.actionId, { navigate });
if (notice.cta.dismissOnAction !== false) onDismiss(); if (cta.dismissOnAction !== false) onDismiss();
} }
} }
if (!notice.cta) return null; if (cta.kind === 'nav' || cta.kind === 'link') {
if (notice.cta.kind === 'nav' || notice.cta.kind === 'link') {
return ( return (
<a <a
href={notice.cta.href} href={cta.href}
onClick={e => { e.preventDefault(); handleClick(); }} onClick={e => { e.preventDefault(); handleClick(); }}
className="underline hover:no-underline font-medium ml-3 shrink-0" className="underline hover:no-underline font-medium ml-3 shrink-0"
> >
@@ -94,7 +91,7 @@ function CTALink({
); );
} }
function BannerItem({ notice, onDismiss, language }: BannerItemProps) { function BannerItem({ notice, onDismiss }: BannerItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const s = SEVERITY[notice.severity] ?? SEVERITY.info; const s = SEVERITY[notice.severity] ?? SEVERITY.info;
const title = t(notice.titleKey); const title = t(notice.titleKey);
@@ -121,7 +118,7 @@ function BannerItem({ notice, onDismiss, language }: BannerItemProps) {
<span className="ml-2 opacity-80">{body}</span> <span className="ml-2 opacity-80">{body}</span>
)} )}
{ctaLabel && notice.cta && ( {ctaLabel && notice.cta && (
<CTALink notice={notice} label={ctaLabel} onDismiss={onDismiss} /> <CTALink notice={notice} cta={notice.cta} label={ctaLabel} onDismiss={onDismiss} />
)} )}
</div> </div>
{notice.dismissible && ( {notice.dismissible && (
@@ -140,10 +137,9 @@ function BannerItem({ notice, onDismiss, language }: BannerItemProps) {
interface AnimatedBannerItemProps { interface AnimatedBannerItemProps {
notice: SystemNoticeDTO; notice: SystemNoticeDTO;
onDismiss: () => void; onDismiss: () => void;
language: string;
} }
function AnimatedBannerItem({ notice, onDismiss, language }: AnimatedBannerItemProps) { function AnimatedBannerItem({ notice, onDismiss }: AnimatedBannerItemProps) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const prefersReducedMotion = const prefersReducedMotion =
typeof window !== 'undefined' && typeof window !== 'undefined' &&
@@ -164,7 +160,7 @@ function AnimatedBannerItem({ notice, onDismiss, language }: AnimatedBannerItemP
return ( return (
<div className={`${transition} ${state}`}> <div className={`${transition} ${state}`}>
<BannerItem notice={notice} onDismiss={onDismiss} language={language} /> <BannerItem notice={notice} onDismiss={onDismiss} />
</div> </div>
); );
} }
@@ -175,7 +171,6 @@ interface BannerRendererProps {
export function BannerRenderer({ notices }: BannerRendererProps) { export function BannerRenderer({ notices }: BannerRendererProps) {
const { dismiss } = useSystemNoticeStore(); const { dismiss } = useSystemNoticeStore();
const { language } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// Show at most 2 highest-priority banners // Show at most 2 highest-priority banners
@@ -209,7 +204,6 @@ export function BannerRenderer({ notices }: BannerRendererProps) {
<AnimatedBannerItem <AnimatedBannerItem
notice={notice} notice={notice}
onDismiss={() => dismiss(notice.id)} onDismiss={() => dismiss(notice.id)}
language={language}
/> />
</React.Fragment> </React.Fragment>
))} ))}

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