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

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

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
This commit is contained in:
Maurice
2026-07-29 11:57:24 +02:00
committed by Maurice
parent a4bf815d24
commit 72a82b3c44
425 changed files with 3155 additions and 1968 deletions
+1
View File
@@ -51,6 +51,7 @@ yarn-error.log*
# Coverage
coverage
coverage-*/
*.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
+31 -3
View File
@@ -1,4 +1,4 @@
// FE-APIWIRE-001 to FE-APIWIRE-035
// FE-APIWIRE-001 to FE-APIWIRE-036
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { 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
View File
@@ -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' } })
+3 -7
View File
@@ -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();
});
});
+3 -2
View File
@@ -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')}
/>
+5 -5
View File
@@ -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])
+9 -5
View File
@@ -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', () => {
+17 -5
View File
@@ -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)');
});
});
+10 -11
View File
@@ -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)
+11 -1
View File
@@ -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' })]} />)
+7 -22
View File
@@ -36,15 +36,6 @@ L.Icon.Default.mergeOptions({
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
})
/**
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
*/
function escAttr(s) {
if (!s) return ''
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
const iconCache = new Map<string, L.DivIcon>()
// 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 })
+28 -14
View File
@@ -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
+10 -182
View File
@@ -1,4 +1,4 @@
import { Fragment, createElement, useEffect, useMemo, useRef, useState } from 'react'
import { Fragment, createElement, useMemo, useState } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { 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])
+18
View File
@@ -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])
+12
View File
@@ -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')
})
})
+6 -4
View File
@@ -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' })
+92 -249
View File
@@ -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