mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -51,6 +51,7 @@ yarn-error.log*
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
coverage-*/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 { AxiosError, type AxiosAdapter, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
@@ -184,6 +184,20 @@ describe('client > rate-limit translation', () => {
|
||||
const err = await captureError(() => apiClient.get('/limited'))
|
||||
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', () => {
|
||||
@@ -398,6 +412,7 @@ describe('client > adminApi.llmLocalPull', () => {
|
||||
read: async () => (i < chunks.length
|
||||
? { done: false, value: encoder.encode(chunks[i++]) }
|
||||
: { done: true, value: undefined }),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
},
|
||||
} as unknown as Response
|
||||
@@ -450,13 +465,26 @@ describe('client > adminApi.llmLocalPull', () => {
|
||||
.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({
|
||||
ok: true, status: 200, body: null,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn()))
|
||||
.rejects.toThrow('Pull failed (200)')
|
||||
.rejects.toThrow('Pull returned no progress stream')
|
||||
})
|
||||
})
|
||||
|
||||
+29
-13
@@ -105,6 +105,9 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
||||
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 {
|
||||
@@ -228,9 +231,11 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
const data = error.response.data as { error?: string } | undefined
|
||||
if (data && typeof data === 'object') {
|
||||
data.error = translated
|
||||
const data = error.response.data
|
||||
// Only a plain object body carries an `error` field worth overwriting;
|
||||
// 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 {
|
||||
error.response.data = { error: translated }
|
||||
}
|
||||
@@ -551,24 +556,35 @@ export const adminApi = {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseUrl, model }),
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
if (!res.ok) {
|
||||
let msg = `Pull failed (${res.status})`
|
||||
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
|
||||
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 dec = new TextDecoder()
|
||||
let buf = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += dec.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ }
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += dec.decode(value, { stream: true })
|
||||
// split() always yields at least one element; the last one is the
|
||||
// trailing (possibly partial) line carried into the next chunk.
|
||||
const lines = buf.split('\n')
|
||||
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),
|
||||
|
||||
@@ -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();
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
@@ -575,6 +575,8 @@ describe('AddonManager', () => {
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
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());
|
||||
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1242,6 +1242,21 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
|
||||
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 () => {
|
||||
discoverWith({ compatible: false, latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
@@ -96,15 +96,19 @@ interface RegistryDetail extends RegistryItem {
|
||||
size: number | null
|
||||
publishedAt: string | null
|
||||
manifest: {
|
||||
permissions: string[]
|
||||
egress: string[]
|
||||
// Optional because a registry may omit an empty list — the detail view has to
|
||||
// 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. */
|
||||
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
|
||||
icon: string | null
|
||||
requiredAddons?: string[]
|
||||
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
|
||||
}
|
||||
|
||||
@@ -300,13 +304,8 @@ function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: s
|
||||
return { blocked: true, label: t('admin.plugins.incompatible'), title }
|
||||
}
|
||||
|
||||
function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) {
|
||||
if (compact) 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>
|
||||
)
|
||||
function ReviewedBadge({ t }: { t: T }) {
|
||||
return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.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">
|
||||
<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.reviewed_at && <ReviewedBadge t={t} compact />}
|
||||
{p.reviewed_at && <ReviewedBadge t={t} />}
|
||||
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
|
||||
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
|
||||
{/* 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])
|
||||
|
||||
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 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
|
||||
@@ -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 items-center gap-2 flex-wrap">
|
||||
<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>
|
||||
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
|
||||
</div>
|
||||
@@ -1451,7 +1453,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
{manifest && (
|
||||
<div className="mt-5">
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
</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">
|
||||
<Database size={15} className="text-accent mt-0.5 shrink-0" /><span>{t('admin.plugins.perm.db:own')}</span>
|
||||
</div>
|
||||
@@ -1470,11 +1472,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manifest && (manifest.egress.length > 0 || manifest.operatorEgress) && (
|
||||
{manifest && (egress.length > 0 || manifest.operatorEgress) && (
|
||||
<div className="mt-5">
|
||||
<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">
|
||||
{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>
|
||||
))}
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{manifest && manifest.settings.length > 0 && (
|
||||
{manifest && settings.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<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">
|
||||
{manifest.settings.map(s => (
|
||||
{settings.map(s => (
|
||||
<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="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)
|
||||
}
|
||||
|
||||
// The Save button carries disabled={… || !form.name.trim()}, so the name is set here.
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingId) {
|
||||
|
||||
@@ -48,8 +48,8 @@ function normalizeProvider(value: unknown): MapProvider {
|
||||
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
||||
}
|
||||
|
||||
function styleForProvider(provider: MapProvider, style?: string | null): string {
|
||||
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
||||
/** Only the GL providers keep a style — Leaflet is handled by its callers. */
|
||||
function styleForProvider(provider: GlMapProvider, style?: string | null): string {
|
||||
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_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(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
|
||||
@@ -120,7 +133,7 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
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 () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { adminApi, tripsApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import {
|
||||
@@ -46,8 +47,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
try {
|
||||
await adminApi.sendTestNotification(payload)
|
||||
toast.success(`Sent: ${label}`)
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, 'Failed'))
|
||||
} finally {
|
||||
setSending(null)
|
||||
}
|
||||
|
||||
@@ -697,7 +697,7 @@ describe('PackingTemplateManager', () => {
|
||||
|
||||
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('T-shirt')).toBeInTheDocument();
|
||||
});
|
||||
@@ -798,7 +798,7 @@ describe('PackingTemplateManager', () => {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -115,12 +115,13 @@ export default function PackingTemplateManager() {
|
||||
await adminApi.deleteTemplateCategory(expandedId, catId)
|
||||
setCategories(prev => prev.filter(c => c.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
|
||||
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 {
|
||||
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
|
||||
setItems(prev => [...prev, data.item])
|
||||
@@ -143,7 +144,7 @@ export default function PackingTemplateManager() {
|
||||
try {
|
||||
await adminApi.deleteTemplateItem(expandedId, 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'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calcPP, hasCustomMemberSplit } from './BudgetPanel.helpers'
|
||||
import { calcPP, hasCustomMemberSplit, normalizePastedAmount } from './BudgetPanel.helpers'
|
||||
|
||||
describe('BudgetPanel.helpers', () => {
|
||||
describe('hasCustomMemberSplit (#1458)', () => {
|
||||
@@ -25,4 +25,18 @@ describe('BudgetPanel.helpers', () => {
|
||||
expect(calcPP(100, 0)).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) {
|
||||
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 { Plus } from 'lucide-react'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { normalizePastedAmount } from './BudgetPanel.helpers'
|
||||
|
||||
interface AddItemRowProps {
|
||||
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 style={{ padding: '4px 6px' }}>
|
||||
<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' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { normalizePastedAmount } from './BudgetPanel.helpers'
|
||||
|
||||
interface InlineEditCellProps {
|
||||
value: string | number | null | undefined
|
||||
@@ -29,21 +30,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
if (type !== 'number') return
|
||||
e.preventDefault()
|
||||
let text = e.clipboardData.getData('text').trim()
|
||||
// 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)
|
||||
setEditValue(normalizePastedAmount(e.clipboardData.getData('text')))
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
|
||||
@@ -170,6 +170,35 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
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 () => {
|
||||
// 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' } })
|
||||
|
||||
@@ -11,6 +11,7 @@ import { budgetApi } from '../../api/client'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters'
|
||||
import { downloadBlob } from '../../utils/fileDownload'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -319,13 +320,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
|
||||
const bom = ''
|
||||
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()
|
||||
a.download = `costs-${safeName}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob(blob, `costs-${safeName}.csv`)
|
||||
}
|
||||
|
||||
// ── 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={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<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' }}>
|
||||
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
||||
</div>
|
||||
|
||||
@@ -218,12 +218,9 @@ export function ChatMessages(props: any) {
|
||||
borderRadius: 99, background: 'var(--bg-card)',
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
)
|
||||
})}
|
||||
{msg.reactions.map(r => (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
))}
|
||||
</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 { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import type { ChatReaction } from './CollabChat.types'
|
||||
@@ -155,4 +155,33 @@ describe('WebsiteThumbnail', () => {
|
||||
await waitFor(() => expect(linkPreview).toHaveBeenCalled())
|
||||
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
|
||||
// view modal that the smoke tests above do not reach.
|
||||
|
||||
@@ -1802,4 +1802,21 @@ describe('CollabNotes details', () => {
|
||||
await screen.findByText('Newer note');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,7 +271,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
|
||||
{t('collab.notes.title')}
|
||||
</h3>
|
||||
<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' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
@@ -526,7 +526,8 @@ export default function CollabNotes(props: CollabNotesProps) {
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDeleteNoteId !== 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')}
|
||||
message={t('collab.notes.confirmDeleteBody')}
|
||||
/>
|
||||
|
||||
Binary file not shown.
@@ -39,10 +39,10 @@ interface CollabPanelProps {
|
||||
}
|
||||
|
||||
const ALL_TABS = [
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', icon: Sparkles },
|
||||
]
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
||||
@@ -55,7 +55,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
const tabs = useMemo(() =>
|
||||
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
||||
...tab,
|
||||
label: t(tab.labelKey) || tab.fallback,
|
||||
label: t(tab.labelKey),
|
||||
})),
|
||||
[features, t])
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
{/* 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>
|
||||
|
||||
{/* Options */}
|
||||
@@ -208,6 +208,8 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
||||
const remaining = timeRemaining(poll.deadline)
|
||||
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 (
|
||||
<div style={{
|
||||
@@ -271,10 +273,12 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
const count = opt.voters?.length || 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 isWinner = isClosed && count === Math.max(...(poll.options || []).map(o => o.voters?.length || 0)) && count > 0
|
||||
const isWinner = isClosed && count > 0 && count === topCount
|
||||
|
||||
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}
|
||||
style={{
|
||||
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%',
|
||||
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)'}
|
||||
>
|
||||
{/* Progress bar background */}
|
||||
@@ -474,7 +478,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
<>
|
||||
{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' }}>
|
||||
{t('collab.polls.closedSection') || 'Closed'}
|
||||
{t('collab.polls.closedSection')}
|
||||
</div>
|
||||
)}
|
||||
{closedPolls.map(poll => (
|
||||
|
||||
@@ -261,6 +261,14 @@ describe('AddPlaceToCollectionModal', () => {
|
||||
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', () => {
|
||||
setup()
|
||||
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} />
|
||||
<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={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>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
/** 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). */
|
||||
function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
current: string | number
|
||||
@@ -93,13 +115,8 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
setLoading(true)
|
||||
collectionsApi.list()
|
||||
.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
|
||||
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 })))
|
||||
setPlaces(merged)
|
||||
})
|
||||
|
||||
@@ -233,7 +233,8 @@ describe('CollectionPlaceDetail', () => {
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
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.
|
||||
await waitFor(() => expect(document.querySelector('.animate-spin')).toBeNull());
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import PlaceRating from '../shared/StarRating'
|
||||
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
|
||||
function linkHost(url: string): string {
|
||||
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
||||
|
||||
@@ -322,7 +322,8 @@ describe('ListEditorModal', () => {
|
||||
const { props } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
|
||||
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()
|
||||
// The save button releases again so the user can retry.
|
||||
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 { useToast } from '../shared/Toast'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { normalizeLinkUrl } from '../../pages/collections/collectionsModel'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { Collection, CollectionLink } from '@trek/shared'
|
||||
|
||||
@@ -219,7 +219,7 @@ describe('SaveToCollectionModal', () => {
|
||||
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;
|
||||
const save = vi.spyOn(collectionsApi, 'savePlace').mockReturnValue(new Promise(r => { resolve = r; }));
|
||||
openFor();
|
||||
@@ -228,7 +228,9 @@ describe('SaveToCollectionModal', () => {
|
||||
const wishlist = await screen.findByRole('button', { name: /Wishlist/ });
|
||||
fireEvent.click(wishlist);
|
||||
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);
|
||||
|
||||
resolve({});
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
key={list.id}
|
||||
type="button"
|
||||
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'}`}
|
||||
>
|
||||
<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));
|
||||
renderModal();
|
||||
|
||||
const search = await screen.findByPlaceholderText('Search trips');
|
||||
const search = await screen.findByPlaceholderText('Search lists');
|
||||
fireEvent.change(search, { target: { value: 'list 4' } });
|
||||
expect(screen.getByText('List 4')).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 () => {
|
||||
renderModal();
|
||||
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 () => {
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function SaveTripPlacesToListModal({ isOpen, tripId, placeIds, on
|
||||
autoFocus
|
||||
value={search}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -114,6 +114,7 @@ export default function ShareCollectionModal({
|
||||
}
|
||||
|
||||
const handleSetRole = async (userId: number, role: CollectionRole) => {
|
||||
if (settingRoleId != null) return
|
||||
setSettingRoleId(userId)
|
||||
try {
|
||||
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 type { TripFile } from '../../types'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
@@ -208,4 +208,41 @@ describe('ImageLightbox', () => {
|
||||
|
||||
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 { useTranslation } from '../../i18n'
|
||||
import type { TripFile } from '../../types'
|
||||
@@ -41,7 +41,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
}, [onClose])
|
||||
|
||||
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 }) {
|
||||
const fileIsVideo = isVideo(file.mime_type)
|
||||
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
|
||||
// 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 (
|
||||
<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',
|
||||
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)',
|
||||
|
||||
@@ -54,6 +54,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
|
||||
setShowTrash(v => !v)
|
||||
}, [showTrash, loadTrash])
|
||||
|
||||
// onUpdate doubles as the "files changed" signal towards the parent; the arguments carry no payload.
|
||||
const refreshFiles = useCallback(async () => {
|
||||
if (onUpdate) onUpdate(0, {} as any)
|
||||
}, [onUpdate])
|
||||
|
||||
@@ -208,13 +208,20 @@ describe('NoFearShow', () => {
|
||||
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()} />)
|
||||
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).toHaveBeenLastCalledWith(800, 600)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-006: shows no line before the first cue', () => {
|
||||
@@ -248,10 +255,9 @@ describe('NoFearShow', () => {
|
||||
expect(ghost).not.toBeNull()
|
||||
expect(ghost?.querySelectorAll('.fz-char-decay')).toHaveLength(LINES.afraid.length)
|
||||
|
||||
// The 1.4s retirement timer captures `lastCue` by reference, and the loop has
|
||||
// already advanced it — so the faded-out ghost node stays in the DOM.
|
||||
// Once its letters have decayed the ghost retires itself.
|
||||
act(() => { vi.advanceTimersByTime(1400) })
|
||||
expect(document.querySelector('.fz-line-ghost')).not.toBeNull()
|
||||
expect(document.querySelector('.fz-line-ghost')).toBeNull()
|
||||
|
||||
frame(12.1)
|
||||
const ghostText = document.querySelector('.fz-line-ghost')?.textContent ?? ''
|
||||
@@ -572,6 +578,13 @@ describe('NoFearShow', () => {
|
||||
frame(76)
|
||||
expect(stubs.assembly).toHaveLength(1)
|
||||
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', () => {
|
||||
|
||||
@@ -114,10 +114,20 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
|
||||
cv.height = Math.round(cv.clientHeight * dpr)
|
||||
cv.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
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()
|
||||
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:
|
||||
// without them the show falls back to its generic lines.
|
||||
void (async () => {
|
||||
@@ -163,7 +173,8 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
|
||||
if (reducedMotion) skipToEnd()
|
||||
return () => {
|
||||
document.body.classList.remove('fz-show-open')
|
||||
window.removeEventListener('resize', fit)
|
||||
window.clearTimeout(refitTimer)
|
||||
window.removeEventListener('resize', onResize)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
window.removeEventListener('pointerdown', onPointer)
|
||||
aborter.abort()
|
||||
@@ -191,10 +202,11 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
|
||||
if (idx !== lastCue && idx >= 0) {
|
||||
// A replaced fear-act line decays letter by letter instead of vanishing.
|
||||
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) {
|
||||
setGhost({ text: copy.lines[prevKey], id: lastCue })
|
||||
window.setTimeout(() => setGhost(g => (g?.id === lastCue ? null : g)), 1400)
|
||||
setGhost({ text: copy.lines[prevKey], id: ghostId })
|
||||
window.setTimeout(() => setGhost(g => (g?.id === ghostId ? null : g)), 1400)
|
||||
}
|
||||
}
|
||||
lastCue = idx
|
||||
|
||||
@@ -213,6 +213,23 @@ describe('TextAssembly.init', () => {
|
||||
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', () => {
|
||||
alphaAt = () => 255
|
||||
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.height = Math.max(Math.round(box.height * scale), 1)
|
||||
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
|
||||
c.font = font.replace(/(\d+(?:\.\d+)?)px/, (_, n) => `${Number(n) * scale}px`)
|
||||
c.textAlign = 'center'
|
||||
@@ -40,7 +44,6 @@ export class TextAssembly {
|
||||
c.fillText(text, off.width / 2, off.height / 2)
|
||||
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
|
||||
this.particles = []
|
||||
for (let y = 0; y < off.height; y += step) {
|
||||
for (let x = 0; x < off.width; x += step) {
|
||||
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
|
||||
const thin: Ring = []
|
||||
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
|
||||
if (signal?.aborted) return
|
||||
|
||||
@@ -66,11 +66,11 @@ describe('JournalBody', () => {
|
||||
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 pre = container.querySelector('pre');
|
||||
expect(pre).toBeInTheDocument();
|
||||
expect(pre!.querySelector('code')!.textContent).toContain('const a = 1');
|
||||
const pres = container.querySelectorAll('pre');
|
||||
expect(pres).toHaveLength(1);
|
||||
expect(pres[0].querySelector('code')!.textContent).toContain('const a = 1');
|
||||
});
|
||||
|
||||
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 [inline] = Array.from(container.querySelectorAll('code'));
|
||||
expect(inline.getAttribute('style')).toContain('rgba(255, 255, 255, 0.08)');
|
||||
// react-markdown supplies its own outer <pre>; the styled one is nested inside.
|
||||
expect(container.querySelectorAll('pre')[1].getAttribute('style')).toContain('rgba(255, 255, 255, 0.05)');
|
||||
// the block styling sits on react-markdown's own <pre>, there is no second one
|
||||
const pres = container.querySelectorAll('pre');
|
||||
expect(pres).toHaveLength(1);
|
||||
expect(pres[0].getAttribute('style')).toContain('rgba(255, 255, 255, 0.05)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,19 +41,18 @@ export default function JournalBody({ text, dark }: Props) {
|
||||
strong: ({ children }) => <strong style={{ fontWeight: 600 }}>{children}</strong>,
|
||||
em: ({ children }) => <em>{children}</em>,
|
||||
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 }) => {
|
||||
const isBlock = className?.includes('language-')
|
||||
if (isBlock) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
if (isBlock) return <code>{children}</code>
|
||||
return (
|
||||
<code style={{
|
||||
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()
|
||||
})
|
||||
|
||||
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 () => {
|
||||
useConnectedImmich()
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -86,8 +86,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
|
||||
useEffect(() => {
|
||||
if (photoTab !== 'external' || availableProviders.length > 0 || providersLoading) return
|
||||
let cancelled = false
|
||||
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 () => {
|
||||
try {
|
||||
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 })
|
||||
} catch {}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setAvailableProviders(connected)
|
||||
if (connected.length > 0) setExternalProvider(current => current || connected[0].id)
|
||||
}
|
||||
setAvailableProviders(connected)
|
||||
if (connected.length > 0) setExternalProvider(current => current || connected[0].id)
|
||||
} catch {}
|
||||
if (!cancelled) setProvidersLoading(false)
|
||||
setProvidersLoading(false)
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [photoTab, availableProviders.length])
|
||||
|
||||
const activeExternalProvider = externalProvider || availableProviders[0]?.id || null
|
||||
|
||||
@@ -25,10 +25,6 @@ export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fu
|
||||
})
|
||||
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 => ({
|
||||
id: String(e.id),
|
||||
lat: e.location_lat!,
|
||||
@@ -85,8 +81,6 @@ export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fu
|
||||
{/* Location items */}
|
||||
{items.map(({ entry: e, globalIdx }, itemIdx) => {
|
||||
const isActive = activeLocationId === String(e.id)
|
||||
const isFirst = e.id === firstId
|
||||
const isLast = e.id === lastId
|
||||
const showConnector = itemIdx < items.length - 1
|
||||
|
||||
return (
|
||||
|
||||
@@ -323,6 +323,19 @@ describe('JourneySettingsDialog', () => {
|
||||
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 () => {
|
||||
const user = userEvent.setup()
|
||||
const { onClose } = mountDialog()
|
||||
|
||||
@@ -275,14 +275,17 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
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 && (
|
||||
<AddTripDialog
|
||||
journeyId={journey.id}
|
||||
existingTripIds={journey.trips.map((t: any) => t.trip_id)}
|
||||
onClose={() => setShowAddTrip(false)}
|
||||
onAdded={() => { setShowAddTrip(false); onSaved() }}
|
||||
/>
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<AddTripDialog
|
||||
journeyId={journey.id}
|
||||
existingTripIds={journey.trips.map((t: any) => t.trip_id)}
|
||||
onClose={() => setShowAddTrip(false)}
|
||||
onAdded={() => { setShowAddTrip(false); onSaved() }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -154,10 +154,6 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove()
|
||||
mapRef.current = null
|
||||
}
|
||||
markersRef.current.clear()
|
||||
|
||||
const map = L.map(containerRef.current, {
|
||||
|
||||
@@ -549,6 +549,20 @@ describe('JourneyMapGL', () => {
|
||||
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', () => {
|
||||
withToken()
|
||||
vi.mocked(isStandardFamily).mockReturnValue(true)
|
||||
|
||||
@@ -233,6 +233,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
onMarkerClickRef.current = onMarkerClick
|
||||
const darkRef = useRef(dark)
|
||||
darkRef.current = dark
|
||||
const mapLangRef = useRef(mapLang)
|
||||
mapLangRef.current = mapLang
|
||||
|
||||
const showPopup = useCallback((id: string) => {
|
||||
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
|
||||
// browser/OS locale and stack multiple scripts per place (#1299).
|
||||
if (!isMapLibre && isStandardFamily(glStyle)) {
|
||||
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support it */ }
|
||||
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLangRef.current)) } catch { /* style/SDK may not support it */ }
|
||||
}
|
||||
|
||||
// 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])
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
if (!activeMarkerId || !mapRef.current) return
|
||||
|
||||
@@ -63,6 +63,11 @@ describe('MobileEntryCard', () => {
|
||||
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', () => {
|
||||
const publicPhotoUrl = (id: number) => `/public/p/${id}.jpg`
|
||||
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 { formatLocationName } from '../../utils/formatters'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||
amazing: Laugh,
|
||||
@@ -25,8 +25,14 @@ const WEATHER_ICONS: Record<string, typeof Sun> = {
|
||||
cold: Snowflake,
|
||||
}
|
||||
|
||||
function photoUrl(p: JourneyPhoto): string {
|
||||
return `/api/photos/${p.photo_id}/thumbnail`
|
||||
// Shared journeys hand us photos that only carry `id`, the journey's own
|
||||
// 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 {
|
||||
@@ -53,11 +59,12 @@ export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, o
|
||||
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : ''
|
||||
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null
|
||||
|
||||
const thumbSrc = firstPhoto
|
||||
? publicPhotoUrl
|
||||
? publicPhotoUrl((firstPhoto as any).photo_id ?? (firstPhoto as any).id)
|
||||
: photoUrl(firstPhoto as JourneyPhoto)
|
||||
: null
|
||||
const firstPhotoId = firstPhoto ? photoId(firstPhoto) : undefined
|
||||
const thumbSrc = firstPhotoId == null
|
||||
? null
|
||||
: publicPhotoUrl
|
||||
? publicPhotoUrl(firstPhotoId)
|
||||
: photoUrl(firstPhotoId)
|
||||
|
||||
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
X, Pencil, Trash2, MapPin, Clock, Camera,
|
||||
Laugh, Smile, Meh, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||
ThumbsUp, ThumbsDown, ChevronDown,
|
||||
ThumbsUp, ThumbsDown,
|
||||
} from 'lucide-react'
|
||||
import JournalBody from './JournalBody'
|
||||
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', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -65,6 +65,12 @@ export default function MobileMapTimeline({
|
||||
try { mapRef.current?.highlightMarker(null) } catch {}
|
||||
}
|
||||
}, [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.
|
||||
// More stable than IntersectionObserver thresholds when the active card can
|
||||
@@ -129,13 +135,15 @@ export default function MobileMapTimeline({
|
||||
}
|
||||
}, [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(() => {
|
||||
if (entries.length > 0) {
|
||||
const timer = setTimeout(() => syncMapToCarousel(0), 500)
|
||||
const timer = setTimeout(() => syncMapToCarouselRef.current(activeIndexRef.current), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [entries.length])
|
||||
}, [entries.length, mapEntries.length])
|
||||
|
||||
const activeEntryId = entries[activeIndex]
|
||||
? String(entries[activeIndex].id)
|
||||
|
||||
@@ -1001,6 +1001,15 @@ describe('MapView photo thumbnails', () => {
|
||||
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', () => {
|
||||
useAuthStore.setState({ placesPhotosEnabled: false })
|
||||
render(<MapView places={[buildMapPlace({ id: 24, lat: 48, lng: 2, google_place_id: 'gp-24' })]} />)
|
||||
|
||||
@@ -36,15 +36,6 @@ L.Icon.Default.mergeOptions({
|
||||
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
const iconCache = new Map<string, L.DivIcon>()
|
||||
|
||||
// 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`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a round photo-circle marker.
|
||||
* Shows image_url if available, otherwise category icon in colored circle.
|
||||
*/
|
||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cached = iconCache.get(cacheKey)
|
||||
@@ -329,16 +324,6 @@ interface MapClickHandlerProps {
|
||||
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'
|
||||
|
||||
/**
|
||||
@@ -385,7 +370,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { isCustomPlaceImage } from './placePhoto'
|
||||
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
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
|
||||
// thumb would shadow the user's own image). (#1136)
|
||||
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
|
||||
|
||||
const cached = getCached(cacheKey)
|
||||
@@ -693,7 +678,7 @@ export const MapView = memo(function MapView({
|
||||
|
||||
const markers = useMemo(() => places.map((place) => {
|
||||
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.
|
||||
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
|
||||
@@ -909,6 +909,29 @@ describe('MapViewGL', () => {
|
||||
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 () => {
|
||||
loadOnAttach()
|
||||
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')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
useAuthStore.setState({ placesPhotosEnabled: false })
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { isCustomPlaceImage } from './placePhoto'
|
||||
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
@@ -73,6 +73,20 @@ interface RouteSegment {
|
||||
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 {
|
||||
places: Place[]
|
||||
dayPlaces?: Place[]
|
||||
@@ -315,12 +329,12 @@ function createPoiMarkerElement(category: string): HTMLDivElement {
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
places = NO_PLACES,
|
||||
dayPlaces = NO_PLACES,
|
||||
tripId,
|
||||
routeVias = [],
|
||||
routeVias = NO_ROUTE_VIAS,
|
||||
route = null,
|
||||
routeSegments = [],
|
||||
routeSegments = NO_ROUTE_SEGMENTS,
|
||||
selectedPlaceId = null,
|
||||
hoverDisabled = false,
|
||||
onMarkerClick,
|
||||
@@ -329,17 +343,17 @@ export function MapViewGL({
|
||||
center = DEFAULT_MAP_CENTER,
|
||||
zoom = DEFAULT_MAP_ZOOM,
|
||||
fitKey = 0,
|
||||
dayOrderMap = {},
|
||||
dayOrderMap = NO_DAY_ORDER,
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
hasInspector = false,
|
||||
hasDayDetail = false,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
reservations = NO_RESERVATIONS,
|
||||
visibleConnectionIds = NO_CONNECTION_IDS,
|
||||
showTransitRoutes = true,
|
||||
showReservationStats = false,
|
||||
onReservationClick,
|
||||
pois = [],
|
||||
pois = NO_POIS,
|
||||
onPoiClick,
|
||||
onViewportChange,
|
||||
glProvider = 'mapbox-gl',
|
||||
@@ -816,12 +830,12 @@ export function MapViewGL({
|
||||
if (ev.touches.length !== 1) { cancelLongPress(); return }
|
||||
if ((ev.target as HTMLElement).closest('.mapboxgl-marker, .maplibregl-marker')) return
|
||||
const t = ev.touches[0]
|
||||
lpStart = { x: t.clientX, y: t.clientY }
|
||||
const start = { x: t.clientX, y: t.clientY }
|
||||
lpStart = start
|
||||
lpTimer = window.setTimeout(() => {
|
||||
lpTimer = null
|
||||
if (!lpStart) return
|
||||
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
|
||||
// Only suppress the tap when OUR fire opened the form — if the native
|
||||
// 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
|
||||
// fetched thumb would shadow the user's own image). (#1136)
|
||||
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
|
||||
const cached = getCached(cacheKey)
|
||||
if (cached?.thumbDataUrl) {
|
||||
@@ -1002,7 +1016,7 @@ export function MapViewGL({
|
||||
|
||||
visiblePlaces.forEach(place => {
|
||||
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.
|
||||
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
|
||||
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 }}>
|
||||
{POI_CATEGORIES.map(cat => {
|
||||
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 (
|
||||
<Tooltip key={cat.key} label={t(cat.labelKey)} placement="bottom">
|
||||
<button
|
||||
|
||||
@@ -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 { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
@@ -10,8 +10,6 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
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'
|
||||
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 {
|
||||
const { icon: IconCmp, color } = TYPE_META[type]
|
||||
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
|
||||
return L.divIcon({
|
||||
className: 'trek-endpoint-marker',
|
||||
@@ -57,7 +55,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
||||
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;
|
||||
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],
|
||||
iconAnchor: [estWidth / 2, 11],
|
||||
popupAnchor: [0, -11],
|
||||
@@ -135,182 +133,18 @@ interface TransportItem {
|
||||
type: TransportType
|
||||
arcs: [number, number][][]
|
||||
transitSegs: TransitMapSegment[]
|
||||
primaryArc: [number, number][]
|
||||
fallback: [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
|
||||
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 {
|
||||
reservations: Reservation[]
|
||||
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
|
||||
onEndpointClick?: (reservationId: number) => void
|
||||
// Real road-network geometry for car/bus/taxi/bicycle bookings, keyed by
|
||||
@@ -318,7 +152,7 @@ interface Props {
|
||||
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()
|
||||
const map = useMap()
|
||||
const [zoom, setZoom] = useState(() => map.getZoom())
|
||||
@@ -354,12 +188,6 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
arcs.push(...segArcs)
|
||||
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 distance = `${Math.round(distanceKm)} km`
|
||||
// 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 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
|
||||
}, [reservations])
|
||||
|
||||
@@ -8,3 +8,21 @@
|
||||
export function isCustomPlaceImage(url: string | null | undefined): boolean {
|
||||
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(map.listeners('zoomend')).toBe(0)
|
||||
expect(map.listeners('moveend')).toBe(0)
|
||||
expect(map.listeners('render')).toBe(0)
|
||||
expect(map.removeLayer).toHaveBeenCalledWith(RESERVATION_LINE_LAYER_ID)
|
||||
expect(map.removeSource).toHaveBeenCalledWith(RESERVATION_SOURCE_ID)
|
||||
})
|
||||
@@ -287,20 +286,18 @@ describe('ReservationMapboxOverlay lifecycle', () => {
|
||||
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 overlay = attach(map)
|
||||
const handlerFor = (event: string) =>
|
||||
map.on.mock.calls.find(c => c[0] === event)?.[1] as Handler
|
||||
const rerender = handlerFor('moveend')
|
||||
const rotate = handlerFor('render')
|
||||
|
||||
overlay.update([booking()], opts)
|
||||
const drawsBefore = map._sources.get(RESERVATION_SOURCE_ID)!.setData.mock.calls.length
|
||||
overlay.destroy()
|
||||
|
||||
rerender()
|
||||
rotate()
|
||||
expect(map._sources.get(RESERVATION_SOURCE_ID)).toBeUndefined()
|
||||
expect(drawsBefore).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -700,7 +697,7 @@ describe('ReservationMapboxOverlay transit styling', () => {
|
||||
})
|
||||
|
||||
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 overlay = attach(map)
|
||||
overlay.update([booking()], opts)
|
||||
@@ -708,6 +705,7 @@ describe('ReservationMapboxOverlay frame handler', () => {
|
||||
|
||||
map.fire('render')
|
||||
|
||||
expect(map.listeners('render')).toBe(0)
|
||||
expect(map._sources.get(RESERVATION_SOURCE_ID)!.setData.mock.calls).toHaveLength(drawsBefore)
|
||||
expect(glMarkers).toHaveLength(2)
|
||||
expect(glMarkers.every(m => m.remove.mock.calls.length === 0)).toBe(true)
|
||||
|
||||
@@ -102,7 +102,9 @@ interface TransportItem {
|
||||
waypoints: ReservationEndpoint[]
|
||||
type: TransportType
|
||||
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
|
||||
subLabel: string | null
|
||||
}
|
||||
@@ -136,8 +138,6 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
||||
arcs.push(...segArcs)
|
||||
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 distance = `${Math.round(distanceKm)} km`
|
||||
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)
|
||||
const subParts = [duration, distance].filter(Boolean) as string[]
|
||||
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
|
||||
}
|
||||
@@ -165,32 +165,11 @@ function endpointMarkerHtml(type: TransportType, label: string | null): string {
|
||||
"><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 ──────────────────────────────────────────────────────
|
||||
export interface ReservationOverlayOptions {
|
||||
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
|
||||
showEndpointLabels: boolean
|
||||
onEndpointClick?: (reservationId: number) => void
|
||||
@@ -212,7 +191,6 @@ export class ReservationMapboxOverlay {
|
||||
private opts: ReservationOverlayOptions
|
||||
private MarkerCtor: MarkerConstructor
|
||||
private endpointMarkers: GlMarker[] = []
|
||||
private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = []
|
||||
private rerender: () => void
|
||||
private destroyed = false
|
||||
|
||||
@@ -224,7 +202,6 @@ export class ReservationMapboxOverlay {
|
||||
this.setupLayer()
|
||||
map.on('zoomend', this.rerender)
|
||||
map.on('moveend', this.rerender)
|
||||
map.on('render', this.updateStatsRotation)
|
||||
}
|
||||
|
||||
update(reservations: Reservation[], opts: ReservationOverlayOptions, roadRoutes?: Map<number, [number, number][]>) {
|
||||
@@ -238,11 +215,8 @@ export class ReservationMapboxOverlay {
|
||||
this.destroyed = true
|
||||
this.map.off('zoomend', this.rerender)
|
||||
this.map.off('moveend', this.rerender)
|
||||
this.map.off('render', this.updateStatsRotation)
|
||||
this.endpointMarkers.forEach(m => m.remove())
|
||||
this.endpointMarkers = []
|
||||
this.statsMarkers.forEach(s => s.marker.remove())
|
||||
this.statsMarkers = []
|
||||
try {
|
||||
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)
|
||||
@@ -386,34 +360,7 @@ export class ReservationMapboxOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
// Stats badge removed — the floating route/duration label on the arc is no
|
||||
// longer drawn; only the connection line and the airport markers remain.
|
||||
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 */ }
|
||||
}
|
||||
// No stats badge: the floating route/duration label on the arc was removed,
|
||||
// so a rendered overlay is the connection lines plus the airport markers.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
@@ -240,4 +240,34 @@ describe('usePoiExplore', () => {
|
||||
expect(result.current.errorKeys.size).toBe(0)
|
||||
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) {
|
||||
setLoading(key, false)
|
||||
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])
|
||||
|
||||
@@ -899,4 +899,16 @@ describe('downloadTripPDF defaults', () => {
|
||||
expect(endSection).toContain('reservations.span.end: Overnight Bus')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -146,7 +146,9 @@ interface downloadTripPDFProps {
|
||||
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()
|
||||
const loc = _locale || undefined
|
||||
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 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
|
||||
// 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
|
||||
@@ -173,7 +175,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
// entirely (offline export keeps working), and a failed fetch degrades to
|
||||
// per-currency breakdowns instead of mislabeled sums (#1561).
|
||||
const tripCur = (trip?.currency || 'EUR').toUpperCase()
|
||||
const allCostEntries: MoneyEntry[] = Object.values(assignments || {})
|
||||
const allCostEntries: MoneyEntry[] = Object.values(assignments)
|
||||
.flatMap(a => a)
|
||||
.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)
|
||||
@@ -433,7 +435,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
</div>`
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${loc.split('-')[0]}">
|
||||
<html lang="${(loc || 'en').split('-')[0]}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<base href="${window.location.origin}/">
|
||||
|
||||
@@ -229,24 +229,28 @@ describe('KategorieGruppe — bulk actions', () => {
|
||||
await waitFor(() => expect(puts).toEqual([1]))
|
||||
})
|
||||
|
||||
it('FE-W5CAT-012: a throwing toggle during Check All surfaces a save error', async () => {
|
||||
useTripStore.setState({ togglePackingItem: async () => { throw new Error('offline') } })
|
||||
it('FE-W5CAT-012: a failing PUT during Check All is reported by the store, once', async () => {
|
||||
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 })] })
|
||||
openCategoryMenu(container)
|
||||
|
||||
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 () => {
|
||||
useTripStore.setState({ togglePackingItem: async () => { throw new Error('offline') } })
|
||||
it('FE-W5CAT-013: a failing PUT during Uncheck All is reported by the store, once', async () => {
|
||||
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 })] })
|
||||
openCategoryMenu(container)
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -98,19 +98,17 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
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 () => {
|
||||
try {
|
||||
for (const item of Array.from(items)) {
|
||||
if (!item.checked) await togglePackingItem(tripId, item.id, true)
|
||||
}
|
||||
} catch { toast.error(t('packing.toast.saveError')) }
|
||||
for (const item of Array.from(items)) {
|
||||
if (!item.checked) await togglePackingItem(tripId, item.id, true)
|
||||
}
|
||||
}
|
||||
const handleUncheckAll = async () => {
|
||||
try {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
||||
}
|
||||
} catch { toast.error(t('packing.toast.saveError')) }
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
||||
}
|
||||
}
|
||||
const handleDeleteAll = async () => {
|
||||
await onDeleteAll(items)
|
||||
@@ -304,7 +302,8 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
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)' }}
|
||||
/>
|
||||
<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()}
|
||||
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} />
|
||||
|
||||
@@ -120,7 +120,7 @@ function Row({ icon, label, sub, active, onClick }: { icon: React.ReactNode; lab
|
||||
<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' }}
|
||||
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={{ 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>
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function AddressInput({ value, onChange, placeholder, className }
|
||||
<MapPin size={12} className="text-content-faint" style={{ marginTop: 2, flexShrink: 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>
|
||||
{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>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -71,8 +71,9 @@ export default function AirportSelect({ value, onChange, placeholder, style }: P
|
||||
const data = await airportsApi.search(trimmed, controller.signal)
|
||||
setResults(Array.isArray(data) ? data : [])
|
||||
setHighlight(-1)
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'AbortError' && err?.name !== 'CanceledError') {
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string } | null)?.name
|
||||
if (name !== 'AbortError' && name !== 'CanceledError') {
|
||||
setResults([])
|
||||
}
|
||||
} 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,
|
||||
setAccommodation, setHotelForm, setHotelDayRange, setShowHotelPicker, handleRemoveAccommodation }: any) {
|
||||
return (
|
||||
|
||||
@@ -3486,9 +3486,10 @@ describe('DayPlanSidebar', () => {
|
||||
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(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(
|
||||
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')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 1, name: 'Louvre' })
|
||||
|
||||
@@ -6,7 +6,7 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 're
|
||||
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 { 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 ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
@@ -62,7 +62,9 @@ interface DayPlanSidebarProps {
|
||||
onReorder: (dayId: number, orderedIds: number[]) => void
|
||||
onReorderDays?: (orderedIds: 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
|
||||
/** The day route is computed by the planner page itself; kept for the existing call sites. */
|
||||
onRouteCalculated: (route: RouteResult | null) => void
|
||||
onAssignToDay: (placeId: number, dayId: number, position?: number) => void
|
||||
onRemoveAssignment: (dayId: number, assignmentId: number) => void
|
||||
@@ -121,7 +123,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||
onReorder, onReorderDays, onAddDay, onUpdateDayTitle, onRouteCalculated,
|
||||
onReorder, onReorderDays, onAddDay,
|
||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
@@ -175,10 +177,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
return new Set<number>(days.map(d => d.id))
|
||||
})
|
||||
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
|
||||
// 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
|
||||
@@ -220,12 +218,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}, [externalTransportDetail, onExternalTransportDetailHandled])
|
||||
const [timeConfirm, setTimeConfirm] = useState<{
|
||||
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;
|
||||
// For arrow reorder
|
||||
reorderIds?: number[];
|
||||
} | null>(null)
|
||||
const inputRef = useRef(null)
|
||||
const dragDataRef = useRef(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
@@ -289,10 +285,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
prevDayCount.current = days.length
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
e?.stopPropagation()
|
||||
_openEditNote(dayId, note)
|
||||
@@ -793,7 +750,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const confirmTimeRemoval = async () => {
|
||||
if (!timeConfirm) return
|
||||
const saved = { ...timeConfirm }
|
||||
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter, toLegIndex } = saved
|
||||
const { dayId, fromId, fromType, toType, toId, insertAfter, toLegIndex } = saved
|
||||
setTimeConfirm(null)
|
||||
|
||||
// Remove time from assignment
|
||||
@@ -812,29 +769,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
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)
|
||||
|
||||
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) {
|
||||
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)
|
||||
@@ -856,33 +793,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
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 prevLocked = new Set(lockedIds)
|
||||
setLockedIds(prev => {
|
||||
@@ -894,8 +804,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
|
||||
}
|
||||
|
||||
const handleOptimize = async (dayId: number | null = selectedDayId) => {
|
||||
if (!dayId) return
|
||||
const handleOptimize = async (dayId: number) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
if (da.length < 3) return
|
||||
|
||||
@@ -975,27 +884,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
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 entries = days.flatMap(d => (assignments[String(d.id)] || []).map(a => ({
|
||||
amount: Number(a.place?.price) || 0,
|
||||
@@ -1025,8 +913,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
onUpdateDayTitle,
|
||||
onRouteCalculated,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
@@ -1088,14 +974,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
moveNote,
|
||||
expandedDays,
|
||||
setExpandedDays,
|
||||
editingDayId,
|
||||
setEditingDayId,
|
||||
editTitle,
|
||||
setEditTitle,
|
||||
isCalculating,
|
||||
setIsCalculating,
|
||||
routeInfo,
|
||||
setRouteInfo,
|
||||
routeLegs,
|
||||
setRouteLegs,
|
||||
hotelLegs,
|
||||
@@ -1127,7 +1005,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setTransportPosVersion,
|
||||
timeConfirm,
|
||||
setTimeConfirm,
|
||||
inputRef,
|
||||
dragDataRef,
|
||||
scrollContainerRef,
|
||||
initedTransportIds,
|
||||
@@ -1148,17 +1025,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
initTransportPositions,
|
||||
getMergedItems,
|
||||
mergedItemsMap,
|
||||
wouldBreakChronology,
|
||||
applyMergedOrder,
|
||||
handleMergedDrop,
|
||||
confirmTimeRemoval,
|
||||
startEditTitle,
|
||||
saveTitle,
|
||||
handleCalculateRoute,
|
||||
toggleLock,
|
||||
handleOptimize,
|
||||
handleDropOnDay,
|
||||
handleDropOnRow,
|
||||
totalCostLabel,
|
||||
anyGeoAssignment,
|
||||
anyGeoPlace,
|
||||
@@ -1212,8 +1084,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
onUpdateDayTitle,
|
||||
onRouteCalculated,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
@@ -1275,14 +1145,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
moveNote,
|
||||
expandedDays,
|
||||
setExpandedDays,
|
||||
editingDayId,
|
||||
setEditingDayId,
|
||||
editTitle,
|
||||
setEditTitle,
|
||||
isCalculating,
|
||||
setIsCalculating,
|
||||
routeInfo,
|
||||
setRouteInfo,
|
||||
routeLegs,
|
||||
setRouteLegs,
|
||||
hotelLegs,
|
||||
@@ -1314,7 +1176,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
setTransportPosVersion,
|
||||
timeConfirm,
|
||||
setTimeConfirm,
|
||||
inputRef,
|
||||
dragDataRef,
|
||||
scrollContainerRef,
|
||||
initedTransportIds,
|
||||
@@ -1335,17 +1196,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
initTransportPositions,
|
||||
getMergedItems,
|
||||
mergedItemsMap,
|
||||
wouldBreakChronology,
|
||||
applyMergedOrder,
|
||||
handleMergedDrop,
|
||||
confirmTimeRemoval,
|
||||
startEditTitle,
|
||||
saveTitle,
|
||||
handleCalculateRoute,
|
||||
toggleLock,
|
||||
handleOptimize,
|
||||
handleDropOnDay,
|
||||
handleDropOnRow,
|
||||
totalCostLabel,
|
||||
anyGeoAssignment,
|
||||
anyGeoPlace,
|
||||
@@ -1530,83 +1386,65 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
})()}
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{editingDayId === day.id ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
onBlur={() => saveTitle(day.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') saveTitle(day.id); if (e.key === 'Escape') setEditingDayId(null) }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%', border: 'none', outline: 'none',
|
||||
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 style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
|
||||
<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 }}>
|
||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||
</span>
|
||||
{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>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
|
||||
<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 }}>
|
||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||
</span>
|
||||
{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 hasRentals = getActiveRentalsForDay(day.id).length > 0
|
||||
if (!hasAccs && !hasRentals) return null
|
||||
return <div style={{ height: 1, background: 'var(--border-faint)', margin: '5px 0 5px' }} />
|
||||
})()}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'nowrap', minWidth: 0 }}>
|
||||
{(() => {
|
||||
const hasAccs = accommodations.some(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||
const hasRentals = getActiveRentalsForDay(day.id).length > 0
|
||||
if (!hasAccs && !hasRentals) return null
|
||||
return <div style={{ height: 1, background: 'var(--border-faint)', margin: '5px 0 5px' }} />
|
||||
})()}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'nowrap', minWidth: 0 }}>
|
||||
{(() => {
|
||||
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||
// Sort: check-out first, then ongoing stays, then check-in last
|
||||
.sort((a, b) => {
|
||||
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
|
||||
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>
|
||||
)
|
||||
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||
// Sort: check-out first, then ongoing stays, then check-in last
|
||||
.sort((a, b) => {
|
||||
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
|
||||
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
|
||||
})
|
||||
})()}
|
||||
{/* 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>
|
||||
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>
|
||||
))
|
||||
})()}
|
||||
</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 && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<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])
|
||||
if (!isChronological) {
|
||||
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
||||
// Store the new merged order for confirm action
|
||||
setTimeConfirm({ dayId: day.id, fromId: assignment.id, time: timeStr, reorderIds: newOrder.filter(i => i.type === 'place').map(i => i.data.id) })
|
||||
// Describe the swap by its neighbour so the confirm step can
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -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.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>
|
||||
@@ -2088,7 +1935,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
|
||||
const TransportIcon = RES_ICONS[res.type] || Ticket
|
||||
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
|
||||
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)
|
||||
return next
|
||||
})
|
||||
} else if (isSelected) { onToggleRoute?.() }
|
||||
// Desktop: the route is computed for the globally selected day,
|
||||
// so tapping Route on another day first points the selection here.
|
||||
else { onSelectDay(day.id, true); if (!routeShown) onToggleRoute?.() }
|
||||
} else {
|
||||
// Desktop: the tools only render for the selected day, so the
|
||||
// toggle always applies to it.
|
||||
onToggleRoute?.()
|
||||
}
|
||||
}}
|
||||
className={routeActive ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
|
||||
style={{
|
||||
@@ -2558,22 +2407,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
})}
|
||||
</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' }}>
|
||||
{routeInfo && <span>{routeInfo.distance}</span>}
|
||||
{routeInfo && <span className="text-content-faint">·</span>}
|
||||
{routeInfo && <span>{routeInfo.duration}</span>}
|
||||
{/* Time plugins contributed to this day (charging, buffers) — the
|
||||
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}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Zap size={11} strokeWidth={2} />
|
||||
+{formatScheduleMinutes(daySchedule.minutesByDay[day.id])}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -116,10 +116,10 @@ describe('LocationSelect', () => {
|
||||
render(<Host onPick={onPick} />);
|
||||
await user.type(screen.getByRole('textbox'), 'Oberkampf');
|
||||
|
||||
// The row falls back to the address for its title and still prints the
|
||||
// address line underneath, so the same text shows up twice.
|
||||
// The row falls back to the address for its title and skips the address
|
||||
// subtitle, so the text shows up once.
|
||||
const rows = await screen.findAllByText('12 Rue Oberkampf');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
await user.click(rows[0]);
|
||||
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 }} />
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -379,14 +379,35 @@ describe('PlaceFormModal', () => {
|
||||
});
|
||||
|
||||
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' });
|
||||
// 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} />);
|
||||
expect(screen.getByText('Category')).toBeInTheDocument();
|
||||
// onCategoryCreated not called unless the new-category form is shown and submitted
|
||||
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) ────────────────────────────────────────────
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import CollectionPicker from '../Collections/CollectionPicker'
|
||||
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 CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { DEFAULT_FORM, isGoogleMapsUrl, type PlaceFormData } from './PlaceFormModal.helpers'
|
||||
@@ -747,6 +747,15 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
style={{ flex: 1 }}
|
||||
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 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 {
|
||||
onClick: () => void
|
||||
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
|
||||
}, [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 toggleTraveler = (id: number) => setTravelerIds(prev => {
|
||||
@@ -807,7 +794,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
}
|
||||
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
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) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const handleToggle = async () => {
|
||||
try { await toggleReservationStatus(tripId, r.id) }
|
||||
catch { toast.error(t('reservations.toast.updateError')) }
|
||||
}
|
||||
const handleDelete = async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
|
||||
|
||||
@@ -456,7 +456,7 @@ describe('TransitSearchPanel', () => {
|
||||
})
|
||||
const mixed = {
|
||||
...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] })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
@@ -469,9 +469,11 @@ describe('TransitSearchPanel', () => {
|
||||
expect(icon('F10')).toContain('lucide-sailboat')
|
||||
expect(icon('FU')).toContain('lucide-cable-car')
|
||||
expect(icon('AL')).toContain('lucide-cable-car')
|
||||
// Anything else (rail and friends) falls through to lucide's `Train`, which
|
||||
// is an alias of TramFront — so rail and tram currently share a glyph.
|
||||
expect(icon('ICE 599')).toContain('lucide-tram-front')
|
||||
expect(icon('U2')).toContain('lucide-train-front-tunnel')
|
||||
// Anything else (rail and friends) falls through to TrainFront, which stays
|
||||
// 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 () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
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 { TransitMetaBadges } from './transitDisplay'
|
||||
import { transitApi } from '../../api/client'
|
||||
@@ -39,22 +39,24 @@ export interface PickedPlace { name: string; lat: number; lng: number }
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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' },
|
||||
{ key: 'subway', labelKey: 'transit.mode.subway', Icon: TrainFront, modes: 'SUBWAY' },
|
||||
// lucide's `Train` is an alias of TramFront, so rail keeps TrainFront and the
|
||||
// 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: 'bus', labelKey: 'transit.mode.bus', Icon: Bus, modes: 'BUS,COACH' },
|
||||
{ key: 'ferry', labelKey: 'transit.mode.ferry', Icon: Sailboat, modes: 'FERRY' },
|
||||
{ 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) {
|
||||
if (mode === 'WALK') return Footprints
|
||||
if (mode === 'BUS' || mode === 'COACH') return Bus
|
||||
if (mode === 'TRAM') return TramFront
|
||||
if (mode === 'SUBWAY') return TrainFront
|
||||
if (mode === 'SUBWAY') return TrainFrontTunnel
|
||||
if (mode === 'FERRY') return Sailboat
|
||||
if (mode === 'FUNICULAR' || mode === 'AERIAL_LIFT') return CableCar
|
||||
return Train
|
||||
return TrainFront
|
||||
}
|
||||
|
||||
function tzAt(lat: number, lng: number): string {
|
||||
|
||||
@@ -58,7 +58,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'))
|
||||
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
|
||||
const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
|
||||
const isNaverListImportEnabled = true
|
||||
|
||||
const [fileImportOpen, setFileImportOpen] = useState(false)
|
||||
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
|
||||
@@ -106,19 +105,13 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const [listImportLoading, setListImportLoading] = useState(false)
|
||||
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
||||
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
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNaverListImportEnabled && listImportProvider === 'naver') {
|
||||
setListImportProvider('google')
|
||||
}
|
||||
}, [isNaverListImportEnabled, listImportProvider])
|
||||
|
||||
const handleListImport = async () => {
|
||||
if (!listImportUrl.trim()) return
|
||||
setListImportLoading(true)
|
||||
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
|
||||
const provider = listImportProvider
|
||||
try {
|
||||
const enrich = listImportEnrich && canEnrichImport
|
||||
const result = provider === 'google'
|
||||
@@ -186,7 +179,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
if (next.has(catId)) next.delete(catId); else next.add(catId)
|
||||
setCategoryFilters(next)
|
||||
}
|
||||
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||
const [dayPickerPlace, setDayPickerPlace] = useState<Place | null>(null)
|
||||
const [catDropOpen, setCatDropOpen] = useState(false)
|
||||
const [mobileShowDays, setMobileShowDays] = useState(false)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import { authApi, adminApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { UserWithOidc } from '../../types'
|
||||
@@ -106,7 +107,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
if (!backupCodesText) return
|
||||
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>
|
||||
</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')
|
||||
if (!w) return
|
||||
w.document.open()
|
||||
|
||||
@@ -196,7 +196,6 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
setSaving(true)
|
||||
try {
|
||||
const glStyle = provider === 'leaflet' ? mapboxStyle : normalizeStyleForProvider(provider, mapboxStyle)
|
||||
setMapboxStyle(glStyle)
|
||||
// Save into the active provider's own slot so the other provider's style survives.
|
||||
const stylePatch = provider === 'maplibre-gl' ? { maplibre_style: glStyle } : { mapbox_style: glStyle }
|
||||
await updateSettings({
|
||||
@@ -207,6 +206,8 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
mapbox_3d_enabled: mapbox3d,
|
||||
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'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
|
||||
@@ -165,9 +165,9 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
await settingsApi.setBulk({
|
||||
ntfy_topic: ntfyTopic,
|
||||
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'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
@@ -194,7 +194,9 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
const result = await notificationsApi.testNtfy({
|
||||
topic: ntfyTopic,
|
||||
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'))
|
||||
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
|
||||
|
||||
// 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 () => {
|
||||
if (!addPwd) { toast.error(t('settings.passkey.passwordRequired')); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
const options = await authApi.passkey.registerOptions(addPwd)
|
||||
@@ -94,7 +95,6 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!deletePwd) { toast.error(t('settings.passkey.passwordRequired')); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await authApi.passkey.delete(id, deletePwd)
|
||||
|
||||
@@ -192,8 +192,11 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
if (!testPath) return
|
||||
setProviderTesting(prev => ({ ...prev, [provider.id]: true }))
|
||||
try {
|
||||
const payload = buildProviderPayload(provider)
|
||||
const res = cfg.test_post ? await apiClient.post(testPath, payload) : await apiClient.get(testPath)
|
||||
// Only a POST probe carries the form values. A provider that declares just a
|
||||
// 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
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: 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 { Info, AlertTriangle, AlertOctagon, X } from 'lucide-react';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
||||
import { useTranslation } from '../../i18n/index.js';
|
||||
import { isRtlLanguage } from '../../i18n/index.js';
|
||||
import { runNoticeAction } from './noticeActions.js';
|
||||
|
||||
const SEVERITY_ICONS: Record<string, React.ElementType> = {
|
||||
@@ -43,39 +42,37 @@ const SEVERITY = {
|
||||
interface BannerItemProps {
|
||||
notice: SystemNoticeDTO;
|
||||
onDismiss: () => void;
|
||||
language: string;
|
||||
}
|
||||
|
||||
function CTALink({
|
||||
notice,
|
||||
cta,
|
||||
label,
|
||||
onDismiss,
|
||||
}: {
|
||||
notice: SystemNoticeDTO;
|
||||
cta: NonNullable<SystemNoticeDTO['cta']>;
|
||||
label: string;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
function handleClick() {
|
||||
if (!notice.cta) return;
|
||||
if (notice.cta.kind === 'nav') {
|
||||
navigate(notice.cta.href);
|
||||
if (cta.kind === 'nav') {
|
||||
navigate(cta.href);
|
||||
if (notice.dismissible) onDismiss();
|
||||
} else if (notice.cta.kind === 'link') {
|
||||
window.open(notice.cta.href, '_blank', 'noopener,noreferrer');
|
||||
} else if (cta.kind === 'link') {
|
||||
window.open(cta.href, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
runNoticeAction(notice.cta.actionId, { navigate });
|
||||
if (notice.cta.dismissOnAction !== false) onDismiss();
|
||||
runNoticeAction(cta.actionId, { navigate });
|
||||
if (cta.dismissOnAction !== false) onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
if (!notice.cta) return null;
|
||||
|
||||
if (notice.cta.kind === 'nav' || notice.cta.kind === 'link') {
|
||||
if (cta.kind === 'nav' || cta.kind === 'link') {
|
||||
return (
|
||||
<a
|
||||
href={notice.cta.href}
|
||||
href={cta.href}
|
||||
onClick={e => { e.preventDefault(); handleClick(); }}
|
||||
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 s = SEVERITY[notice.severity] ?? SEVERITY.info;
|
||||
const title = t(notice.titleKey);
|
||||
@@ -121,7 +118,7 @@ function BannerItem({ notice, onDismiss, language }: BannerItemProps) {
|
||||
<span className="ml-2 opacity-80">{body}</span>
|
||||
)}
|
||||
{ctaLabel && notice.cta && (
|
||||
<CTALink notice={notice} label={ctaLabel} onDismiss={onDismiss} />
|
||||
<CTALink notice={notice} cta={notice.cta} label={ctaLabel} onDismiss={onDismiss} />
|
||||
)}
|
||||
</div>
|
||||
{notice.dismissible && (
|
||||
@@ -140,10 +137,9 @@ function BannerItem({ notice, onDismiss, language }: BannerItemProps) {
|
||||
interface AnimatedBannerItemProps {
|
||||
notice: SystemNoticeDTO;
|
||||
onDismiss: () => void;
|
||||
language: string;
|
||||
}
|
||||
|
||||
function AnimatedBannerItem({ notice, onDismiss, language }: AnimatedBannerItemProps) {
|
||||
function AnimatedBannerItem({ notice, onDismiss }: AnimatedBannerItemProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
@@ -164,7 +160,7 @@ function AnimatedBannerItem({ notice, onDismiss, language }: AnimatedBannerItemP
|
||||
|
||||
return (
|
||||
<div className={`${transition} ${state}`}>
|
||||
<BannerItem notice={notice} onDismiss={onDismiss} language={language} />
|
||||
<BannerItem notice={notice} onDismiss={onDismiss} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -175,7 +171,6 @@ interface BannerRendererProps {
|
||||
|
||||
export function BannerRenderer({ notices }: BannerRendererProps) {
|
||||
const { dismiss } = useSystemNoticeStore();
|
||||
const { language } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Show at most 2 highest-priority banners
|
||||
@@ -209,7 +204,6 @@ export function BannerRenderer({ notices }: BannerRendererProps) {
|
||||
<AnimatedBannerItem
|
||||
notice={notice}
|
||||
onDismiss={() => dismiss(notice.id)}
|
||||
language={language}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user