Merge remote-tracking branch 'origin/dev' into feat/native-apps

This commit is contained in:
jubnl
2026-08-23 15:27:42 +02:00
345 changed files with 7803 additions and 798 deletions
+7
View File
@@ -11,6 +11,13 @@ on:
- 'client/**'
- 'shared/**'
- 'plugin-sdk/**'
# npm workspaces hoist every dependency into the single root lockfile, so
# a dependency bump touches none of the four directories above — and a
# native binding like better-sqlite3 is exactly what needs the suite.
- 'package.json'
- 'package-lock.json'
- 'Dockerfile'
- 'docker-compose.yml'
- '.github/workflows/test.yml'
jobs:
+32 -14
View File
@@ -2,9 +2,12 @@
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
//
// We scan only the default-export component body (from `export default function`
// up to the next top-level `function` declaration or EOF), so presentational
// sub-components and helper hooks living in the same file are not flagged.
// We scan the default-export component body (from `export default function` up
// to the next top-level `function` declaration or EOF), and the same span of a
// top-level `*PageDesktop`/`*PageMobile` — since ViewportRoute started picking
// the branch, the default export is a two-line shim and the real container is
// one of those two. Everything else at top level stays exempt: presentational
// sub-components and helper hooks live in these files on purpose.
// Context hooks like useTranslation/useParams are fine; the smell is stateful
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
import { readdirSync, readFileSync } from 'node:fs'
@@ -13,23 +16,38 @@ import { fileURLToPath } from 'node:url'
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
// `[<(]` rather than `\(`: a generic call — `useRef<() => void>(null)` — is the
// same thing, and it slipped through for as long as the pattern only knew `(`.
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*[<(]`)
// Declarations the widened scan caught that are not this change's to move.
// Listed by the name they declare rather than by line, so an entry dies with
// its declaration instead of drifting onto whatever follows it. The rest of
// each listed file is still checked.
const KNOWN_ESCAPES = {
'TripPlannerPage.tsx': ['glMap', 'bookingExpense'],
}
const violations = []
for (const file of readdirSync(pagesDir)) {
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
const src = readFileSync(join(pagesDir, file), 'utf8')
const lines = src.split('\n')
const start = lines.findIndex(l => /export default function/.test(l))
if (start === -1) continue
// The page body ends at the next top-level declaration (a `function` at
// column 0) — everything after that is a sub-component or helper.
let end = lines.length
for (let i = start + 1; i < lines.length; i++) {
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
}
for (let i = start; i < end; i++) {
if (bannedRe.test(lines[i])) {
const allowed = KNOWN_ESCAPES[file] ?? []
const starts = []
lines.forEach((line, i) => {
if (/export default function/.test(line) || /^function \w+Page(Desktop|Mobile)\b/.test(line)) starts.push(i)
})
for (const start of starts) {
// The page body ends at the next top-level declaration (a `function` at
// column 0) — everything after that is a sub-component or helper.
let end = lines.length
for (let i = start + 1; i < lines.length; i++) {
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
}
for (let i = start; i < end; i++) {
if (!bannedRe.test(lines[i])) continue
if (allowed.some(name => new RegExp(`\\b${name}\\b`).test(lines[i]))) continue
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
}
}
+26 -1
View File
@@ -7,7 +7,8 @@ import { server } from '../tests/helpers/msw/server'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { resetAllStores } from '../tests/helpers/store'
import { buildUser, buildSettings } from '../tests/helpers/factories'
import { buildUser, buildSettings, buildTrip } from '../tests/helpers/factories'
import { offlineDb } from './db/offlineDb'
import { SETTINGS_WAIT_MS } from './utils/startDestination'
import App from './App'
@@ -142,6 +143,30 @@ describe('RootRedirect — startup destination', () => {
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
it('FE-COMP-APP-028b: opens the cached active trip when the launch is offline', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({
isLoaded: true,
settings: buildSettings({ start_page: 'active_trip' }),
})
const today = new Date()
const iso = (d: Date) => d.toISOString().slice(0, 10)
await offlineDb.trips.put(buildTrip({ id: 42, title: 'Japan', start_date: iso(today), end_date: iso(today) }))
const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, 'onLine')
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
// navigator and the Dexie table are shared with every other test in the
// file, so a failed assertion must not leave the rest of them offline.
try {
renderApp('/')
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
} finally {
if (onLine) Object.defineProperty(Navigator.prototype, 'onLine', onLine)
delete (navigator as unknown as { onLine?: boolean }).onLine
await offlineDb.trips.clear()
}
})
// The whole point of the localStorage mirror: the default launch must not pay
// for a lookup it doesn't need.
it('FE-COMP-APP-029: never asks for the active trip when starting on the dashboard', async () => {
+5 -2
View File
@@ -19,7 +19,8 @@ import ErrorBoundary from './components/shared/ErrorBoundary'
import { lazyWithRetry } from './utils/lazyWithRetry'
import { useIsPhone } from './mobile/useIsPhone'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi, tripsApi } from './api/client'
import { authApi } from './api/client'
import { tripRepo } from './repo/tripRepo'
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
@@ -244,7 +245,9 @@ function RootRedirect() {
: (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB })
if (page !== 'active_trip') { setTarget('/dashboard'); return }
let cancelled = false
tripsApi.active()
// Through the repo, not the api: offline this answers from Dexie instead of
// bouncing a cached trip to the dashboard.
tripRepo.active()
.then(({ trip }) => { if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard') })
.catch(() => { if (!cancelled) setTarget('/dashboard') })
return () => { cancelled = true }
+16 -2
View File
@@ -1,4 +1,4 @@
// FE-APISURF-001 to FE-APISURF-052
// FE-APISURF-001 to FE-APISURF-054
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw'
@@ -9,7 +9,7 @@ import {
tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi,
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi,
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
} from './client'
interface Recorded { method: string; url: string; body: unknown }
@@ -396,6 +396,20 @@ describe('client > endpoint wiring', () => {
])
})
it('FE-APISURF-053: memoriesApi maps the photo-provider endpoints', async () => {
await assertCalls([
{ n: 'status', r: () => memoriesApi.status('immich'), e: 'GET /api/integrations/memories/immich/status' },
{ n: 'search', r: () => memoriesApi.search('immich', { from: '2026-01-01', to: '2026-01-02', page: 1, size: 50 }), e: 'POST /api/integrations/memories/immich/search' },
{ n: 'albums', r: () => memoriesApi.albums('immich'), e: 'GET /api/integrations/memories/immich/albums' },
{ n: 'albumPhotos', r: () => memoriesApi.albumPhotos('immich', 'alb-1'), e: 'GET /api/integrations/memories/immich/albums/alb-1/photos' },
])
})
it('FE-APISURF-054: memoriesApi passes the album passphrase as a query parameter', async () => {
const rec = await traceOne(() => memoriesApi.albumPhotos('synologyphotos', 'alb-2', 'p/w?'))
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
})
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
await assertCalls([
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
+28 -7
View File
@@ -55,6 +55,7 @@ import {
} from '@trek/shared'
import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity'
import { downloadBlob } from '../utils/fileDownload'
/**
* Validate a response payload against its @trek/shared Zod schema — but only in
@@ -968,6 +969,32 @@ export const journeyApi = {
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
}
// Photo providers (Immich, Synology Photos, …) behind /api/integrations/memories.
// The provider sits on the user's own hardware and the server proxies through to
// it, so the 8s default does not apply — and neither does any number picked
// here. The server already holds the deadline, and its worst case is longer than
// anything that would look reasonable in this file: a Synology call is 30s, and
// an expired session makes it re-authenticate and try again. A client cap below
// that turns a slow NAS into a NAS that reads as disconnected, which is what
// these calls looked like before they moved off raw fetch (which had no timeout
// either). Callers that need to give up early pass an AbortSignal.
const MEMORIES_TIMEOUT = 0
export const memoriesApi = {
status: (provider: string): Promise<{ connected: boolean }> =>
apiClient.get(`/integrations/memories/${provider}/status`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
search: (provider: string, body: { from: string; to: string; page: number; size: number }, signal?: AbortSignal) =>
apiClient.post(`/integrations/memories/${provider}/search`, body, { timeout: MEMORIES_TIMEOUT, signal }).then(r => r.data),
albums: (provider: string) =>
apiClient.get(`/integrations/memories/${provider}/albums`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
albumPhotos: (provider: string, albumId: string, passphrase?: string, signal?: AbortSignal) =>
apiClient.get(`/integrations/memories/${provider}/albums/${albumId}/photos`, {
timeout: MEMORIES_TIMEOUT,
params: passphrase ? { passphrase } : undefined,
signal,
}).then(r => r.data),
}
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
@@ -1144,13 +1171,7 @@ export const backupApi = {
credentials: 'include',
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
downloadBlob(await res.blob(), filename)
},
delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
+24 -3
View File
@@ -1,7 +1,20 @@
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-011
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes'
// The consent page mirrors the server's scope list by hand, so read the server
// file and compare. Parsing keeps this a client-only test (no server import).
function readServerScopes(): string[] {
const here = dirname(fileURLToPath(import.meta.url))
const src = readFileSync(resolve(here, '../../../server/src/mcp/scopes.ts'), 'utf8')
const block = src.slice(src.indexOf('export const SCOPES = {'))
const body = block.slice(0, block.indexOf('} as const;'))
return [...body.matchAll(/^\s*[A-Z_]+:\s*'([a-z]+:[a-z]+)'/gm)].map(m => m[1])
}
describe('SCOPE_GROUPS', () => {
it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => {
const expected = [
@@ -34,8 +47,16 @@ describe('SCOPE_GROUPS', () => {
})
describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 29 scopes', () => {
expect(ALL_SCOPES).toHaveLength(29)
it('FE-OAUTH-SCOPES-003: contains exactly as many scopes as the server defines', () => {
expect(ALL_SCOPES).toHaveLength(readServerScopes().length)
})
it('FE-OAUTH-SCOPES-011: matches server/src/mcp/scopes.ts exactly', () => {
const serverScopes = readServerScopes()
// Guards the parse itself: a moved file or a reformatted list must fail
// here rather than quietly compare two empty sets.
expect(serverScopes.length).toBeGreaterThan(0)
expect(new Set(ALL_SCOPES)).toEqual(new Set(serverScopes))
})
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
@@ -1862,3 +1862,43 @@ describe('AdminPluginsPanel signature fingerprints and dismissals', () => {
await waitFor(() => expect(uploaded).toBe(false))
})
})
// FE-W5PLG-030 — Update all drives the same single-slot busy flag as a per-row update,
// so the requests have to go out one after another.
describe('AdminPluginsPanel — update all', () => {
const updatableRow = (over: Record<string, unknown> = {}) => plugin({
id: 'a-widget', name: 'Alpha Widget', type: 'widget', version: '1.0.0', operatorEgress: false, ...over,
})
it('FE-W5PLG-030: Update all sends the second update only after the first came back', async () => {
const started: string[] = []
let release = () => {}
const gate = new Promise<void>(resolve => { release = resolve })
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({
enabled: true, devLink: false,
plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })],
})),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([
{ id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' },
{ id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' },
])),
http.post('*/api/admin/plugins/:id/update', async ({ params }) => {
started.push(String(params.id))
if (started.length === 1) await gate
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
}),
)
render(<AdminPluginsPanel />)
fireEvent.click(await screen.findByRole('button', { name: /update all/i }))
await waitFor(() => expect(started).toHaveLength(1))
// Give a parallel dispatch every chance to show up before releasing the first.
await new Promise(resolve => setTimeout(resolve, 20))
expect(started).toEqual(['a-widget'])
release()
await waitFor(() => expect(started).toEqual(['a-widget', 'b-widget']))
})
})
@@ -641,7 +641,7 @@ export default function AdminPluginsPanel() {
const runUpdate = (p: PluginRow) => {
setBusy(p.id); setMenu(null)
adminApi.pluginUpdate(p.id)
return adminApi.pluginUpdate(p.id)
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
@@ -676,6 +676,12 @@ export default function AdminPluginsPanel() {
// eslint-disable-next-line react-hooks/exhaustive-deps
const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
// One after another: busy is a single slot, so parallel updates would leave the
// other rows clickable mid-install and refresh once per plugin.
const updateAll = async () => {
for (const p of updatable) await runUpdate(p)
}
// Installed list after search / type / status filters + sort.
const shownInstalled = useMemo(() => {
const term = q.trim().toLowerCase()
@@ -865,7 +871,7 @@ export default function AdminPluginsPanel() {
<div className="mx-1.5 sm:mx-3 mb-2 mt-1 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl bg-warning-soft border border-warning/30">
<ArrowUpCircle size={16} className="text-warning shrink-0" />
<span className="text-xs text-content-secondary">{t('admin.plugins.updatesAvailable', { count: updatable.length })}</span>
<button onClick={() => updatable.forEach(runUpdate)}
<button onClick={() => void updateAll()}
className="ml-auto text-xs font-semibold px-3 py-1.5 rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
{t('admin.plugins.updateAll')}
</button>
@@ -1,4 +1,4 @@
// FE-W4BGT-001 to FE-W4BGT-020
// FE-W4BGT-001 to FE-W4BGT-022
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { screen, act, waitFor } from '@testing-library/react'
import { render, fireEvent } from '../../../tests/helpers/render'
@@ -185,6 +185,37 @@ describe('BackgroundTasksWidget — rehydrate', () => {
})
})
describe('BackgroundTasksWidget — poll backstop', () => {
it('FE-W4BGT-021: a job the server drops mid-poll ends as an error instead of spinning on', async () => {
vi.useFakeTimers()
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
vi.mocked(reservationsApi.importJobStatus)
.mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never)
.mockRejectedValue({ response: { status: 404 } })
render(<BackgroundTasksWidget />)
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('error')
// The file parsed fine — blaming it would send the user looking for a problem
// that isn't there.
expect(useBackgroundTasksStore.getState().tasks[0].error).toBe('Unknown error')
})
it('FE-W4BGT-022: a transient poll failure leaves the job running', async () => {
vi.useFakeTimers()
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
vi.mocked(reservationsApi.importJobStatus)
.mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never)
.mockRejectedValue({ response: { status: 500 } })
render(<BackgroundTasksWidget />)
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('running')
})
})
describe('BackgroundTasksWidget — AI retry', () => {
const withFiles = () => task({
items: [], sourceFiles: [new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })],
@@ -111,11 +111,16 @@ export default function BackgroundTasksWidget() {
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch(() => {})
.catch((err: { response?: { status?: number } }) => {
// The server 404s a job once it has expired (or after a restart). Nothing is
// ever coming, so end the card instead of spinning and re-polling forever.
// Not the parse-failure message — the file was fine, the job is just gone.
if (err?.response?.status === 404) setError(task.id, task.tripId, t('common.unknownError'))
})
}
}, 5000)
return () => clearInterval(iv)
}, [tasks, setProgress, setDone, setError])
}, [tasks, setProgress, setDone, setError, t])
if (tasks.length === 0) return null
@@ -10,7 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { usePermissionsStore } from '../../store/permissionsStore'
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'
import type { BudgetItem } from '../../types'
import CostsPanel, { ExpenseModal } from './CostsPanel'
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
@@ -594,7 +594,7 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(screen.getByDisplayValue('100,00')).toBeDisabled()
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText('Individual shares')).toBeInTheDocument()
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
expect(screen.getByText(/25\.00/)).toBeInTheDocument()
@@ -916,6 +916,50 @@ describe('CostsPanel — settle up', () => {
delete window.__addToast
})
it('FE-W5COSTS-059: a settle-up that fails midway still reloads the transfers that went through', async () => {
const addToast = vi.fn()
window.__addToast = addToast as unknown as typeof window.__addToast
let posted = 0
let reads = 0
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => {
reads += 1
return HttpResponse.json({ balances: [], flows: posted > 0 ? [flows[1]] : flows, settlements: [] })
}),
http.post('/api/trips/1/budget/settlements', () => {
posted += 1
return posted === 1 ? HttpResponse.json({ settlement: { id: 1 } }) : HttpResponse.json({ error: 'no' }, { status: 500 })
}),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
const settleAll = await screen.findByRole('button', { name: 'Settle up' })
await waitFor(() => expect(settleAll).not.toBeDisabled())
const readsBefore = reads
fireEvent.click(settleAll)
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
// The failed second transfer must not swallow the refresh: bob's 45 € was
// recorded, and leaving it listed invites a second, doubled settle-up.
await waitFor(() => expect(reads).toBeGreaterThan(readsBefore))
expect(posted).toBe(2)
delete window.__addToast
})
it('FE-W5COSTS-060: a settlement read that fails says so instead of claiming everyone is square', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ error: 'no' }, { status: 500 })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Balances')
// Settle up and Balances both hang off the one failed request.
await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(2))
expect(screen.queryByText("Everyone's square")).toBeNull()
})
it('FE-W5COSTS-013: the "you owe" card lists who I still have to pay', async () => {
mount([], { flows: [{ from: { user_id: 1, username: 'alice' }, to: { user_id: 2, username: 'bob' }, amount: 20 }] })
@@ -1394,6 +1438,38 @@ describe('CostsPanel — expense modal', () => {
expect(onSaved).toHaveBeenCalled()
})
// The mobile sheet already dates a new expense by the traveller's own clock;
// the desktop modal filed it under the UTC day, so the same expense entered
// late in Tokyo or early in Los Angeles landed on a different day per surface.
it('FE-W5COSTS-065: a new expense is dated by the local calendar day, not the UTC one', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
// A wall-clock time on whichever side of midnight puts the runner's UTC date
// on a different day. On UTC itself the two spellings agree and there is
// nothing here to catch.
const behindUtc = new Date(2026, 7, 12).getTimezoneOffset() > 0
vi.setSystemTime(new Date(2026, 7, 12, behindUtc ? 23 : 1, 30, 0))
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
let posted: Record<string, unknown> | null = null
server.use(http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: dinner() })
}))
render(
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
prefill={{ name: 'Ramen', category: 'food', amount: 18 }}
onClose={() => {}} onSaved={vi.fn()} />
)
await user.click(screen.getByRole('button', { name: 'Add expense' }))
try {
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.expense_date).toBe('2026-08-12')
} finally {
vi.useRealTimers()
}
})
it('FE-W5COSTS-038b: a prefill from a place links the expense to that place (#1298)', async () => {
const user = userEvent.setup()
let posted: Record<string, unknown> | null = null
@@ -1447,6 +1523,26 @@ describe('CostsPanel — remaining paths', () => {
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
})
it('FE-W5COSTS-061: the CSV export neutralises names a spreadsheet would run as a formula', async () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR', title: 'Rome' }) })
let exported: Blob | null = null
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' })
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
mount([
expense({ id: 204, name: '=HYPERLINK("http://evil","click")', category: 'food', total_price: 12, expense_date: '2025-06-15', note: '@SUM(A1:A9)' }),
expense({ id: 205, name: '-5 refund', category: 'misc', total_price: 5, expense_date: '2025-06-16', note: null }),
])
await screen.findByText('-5 refund')
fireEvent.click(screen.getByTitle('Export CSV'))
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
expect(lines[1]).toBe('06/15/2025;"\'=HYPERLINK(""http://evil"",""click"")";Food & drink;12.00;EUR;12.00;\'@SUM(A1:A9)')
expect(lines[2]).toBe('06/16/2025;\'-5 refund;Other;5.00;EUR;5.00;')
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
})
it('FE-W5COSTS-043: deleting an expense drops it from the ledger', async () => {
let deleted = false
server.use(http.delete('/api/trips/1/budget/101', () => { deleted = true; return HttpResponse.json({ success: true }) }))
@@ -1615,7 +1711,7 @@ describe('CostsPanel — remaining paths', () => {
await user.click(bob)
await user.click(bob)
// 10.99 across two people leaves the odd cent with the lower user id.
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText('Individual shares')).toBeInTheDocument()
expect(screen.getByText('€5.50')).toBeInTheDocument()
expect(screen.getByText('€5.49')).toBeInTheDocument()
})
@@ -1741,6 +1837,21 @@ describe('CostsPanel — mobile extras', () => {
expect(await screen.findByPlaceholderText('0.00')).toBeInTheDocument()
})
it('FE-W5COSTS-062: the mobile search box keeps the caret across keystrokes', async () => {
const user = userEvent.setup()
mount([dinner(), taxi()])
await screen.findByText('Taxi')
const box = screen.getByPlaceholderText('Search expenses…')
await user.type(box, 'din')
// The whole word has to land: if the body is remounted per keystroke the input
// loses focus and everything after the first character goes nowhere.
expect((box as HTMLInputElement).value).toBe('din')
expect(document.activeElement).toBe(box)
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
})
it('FE-W5COSTS-054: an unknown currency degrades to a plain amount on mobile too', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } })
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'XX' }) })
@@ -1774,3 +1885,58 @@ describe('CostsPanel — split maths', () => {
expect(calculateTicketShares(items)).toEqual({ shares: { 1: 7.5, 2: 7.5 }, total: 15 })
})
})
// The reworked expense modal shipped with English written straight into the JSX,
// so the receipt panel and the split summary stayed English on a German phone
// while everything around them translated.
describe('CostsPanel — expense modal in another language', () => {
beforeEach(() => {
seedAlice()
seedStore(useSettingsStore, { settings: buildSettings({ language: 'de', default_currency: 'EUR' }) })
clearExchangeRateCache()
})
afterEach(clearExchangeRateCache)
const openModal = () => render(
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
onClose={() => {}} onSaved={vi.fn()} />
)
it('FE-W5COSTS-063: the receipt panel is translated', async () => {
const user = userEvent.setup()
openModal()
// The locale bundle is fetched, so the first paint is still English.
await user.click(await screen.findByRole('button', { name: 'Beleg' }))
await user.click(screen.getByRole('button', { name: /Artikel hinzufügen/ }))
expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument()
expect(screen.getByText('Aufteilen auf:')).toBeInTheDocument()
expect(screen.getByText('Anteil pro Person')).toBeInTheDocument()
expect(screen.queryByText('Individual shares')).not.toBeInTheDocument()
})
it('FE-W5COSTS-064: the split summary and the excluded marker are translated', async () => {
const user = userEvent.setup()
openModal()
await user.type(await screen.findByPlaceholderText('0,00'), '90')
await user.click(screen.getByRole('button', { name: 'Individuell' }))
// Nothing entered yet, so the whole 90 is still unaccounted for.
expect(screen.getByText('Summe der Anteile: €0.00 von €90.00 (es fehlen €90.00)')).toBeInTheDocument()
const shares = screen.getAllByPlaceholderText('45,00')
await user.type(shares[0], '90')
expect(screen.getByText('Aufteilung passt zur Summe')).toBeInTheDocument()
await user.type(shares[1], '10')
expect(screen.getByText('Summe der Anteile: €100.00 von €90.00 (€10.00 zu viel)')).toBeInTheDocument()
// Back to the equal split: a member taken out of it is marked, not dropped.
await user.click(screen.getByRole('button', { name: 'Gleichmäßig' }))
await user.click(screen.getByRole('button', { name: /bob/i }))
expect(screen.getByText('Nicht dabei')).toBeInTheDocument()
expect(screen.queryByText('Excluded')).not.toBeInTheDocument()
})
})
+61 -25
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { Fragment, useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
@@ -15,6 +15,7 @@ import { downloadBlob } from '../../utils/fileDownload'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { localToday } from '../Planner/today'
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
import { calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
@@ -74,6 +75,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const { convert } = useExchangeRates(base)
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
const [settlement, setSettlement] = useState<SettlementData | null>(null)
// A failed settlement read leaves `settlement` null, which the empty views would
// otherwise present as "everyone is square", a balance claim we cannot make.
const [settlementError, setSettlementError] = useState(false)
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
const [search, setSearch] = useState('')
const [catFilter, setCatFilter] = useState('') // '' = all categories
@@ -99,7 +103,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
const loadSettlement = useCallback(() => {
budgetApi.settlement(tripId, base).then(setSettlement).catch(() => {})
budgetApi.settlement(tripId, base)
.then(s => { setSettlement(s); setSettlementError(false) })
.catch(() => setSettlementError(true))
}, [tripId, base])
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
@@ -219,8 +225,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
if (!flows.length) return
try {
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base })
loadSettlement()
} catch { toast.error(t('common.unknownError')) }
// Refresh even when one transfer failed: the ones created before it are real,
// and leaving them in the flow list invites a second, doubled settle-up.
finally { loadSettlement() }
}
const dateMeta = useMemo(() => {
@@ -241,7 +249,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// Costs rework (#1500). One row per expense, oldest first.
const handleExportCsv = () => {
const sep = ';'
const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
// A cell starting with =, +, -, @, TAB or CR is evaluated as a formula by Excel
// and Sheets, and the name/note columns are free text any trip member can write.
const esc = (v: unknown) => {
let s = String(v ?? '')
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s
return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s
}
const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } }
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', 'Amount (' + base + ')', 'Note']
@@ -310,7 +324,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return (
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
{isMobile ? <MobileBody /> : (
{isMobile ? MobileBody() : (
<div style={{ maxWidth: '100%', margin: '0 auto' }}>
{/* ── Header bar ── */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}>
@@ -422,8 +436,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}
? <Fragment key={'e' + en.e.id}>{ExpenseRow({ e: en.e })}</Fragment>
: <Fragment key={'s' + en.s.id}>{SettlementRow({ s: en.s })}</Fragment>)}
</div>
</div>
)
@@ -444,19 +458,19 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</button>
)}
</div>
<SettleFlows />
{SettleFlows()}
</div>
{/* balances */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
<BalancesList balances={settlement?.balances || []} />
{BalancesList({ balances: settlement?.balances || [] })}
</div>
{/* by category */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
<CategoryBreakdown />
{CategoryBreakdown()}
</div>
</div>
</div>
@@ -520,8 +534,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
)
// Settle-up and Balances both come from the one settlement request, so they
// share the notice that says the numbers are missing rather than zero.
function loadFailed() {
return <div className="text-content-muted" style={{ textAlign: 'center', padding: '14px 8px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('common.unknownError')}</div>
}
// ── shared settle-flow list ──────────────────────────────────────────────
function SettleFlows() {
if (settlementError) return loadFailed()
const flows = settlement?.flows || []
if (flows.length === 0) return (
<div style={{ textAlign: 'center', padding: '14px 8px' }}>
@@ -548,6 +569,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}
// ── mobile layout (Budget1Mobile.html): single flat column, total card on top ──
//
// Called as a plain function — `{MobileBody()}`, not `<MobileBody />` — and the
// same goes for ExpenseRow, SettleFlows, BalancesList and CategoryBreakdown.
// A component declared inside this one is a fresh component type on every
// render, so React tears the old subtree down and mounts a new one instead of
// updating it; the search box then loses focus after every keystroke. Calling
// them keeps the markup inline where it always was. Do not "tidy" these back
// into elements.
function MobileBody() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
@@ -602,7 +631,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
)}
</div>
<SettleFlows />
{SettleFlows()}
</div>
{/* Expenses */}
@@ -637,8 +666,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!dayFilter && <div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
? <Fragment key={'e' + en.e.id}>{ExpenseRow({ e: en.e })}</Fragment>
: <Fragment key={'s' + en.s.id}>{SettlementRow({ s: en.s })}</Fragment>)}</div>
</div>
)
})}
@@ -647,13 +676,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* Balances */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
<BalancesList balances={settlement?.balances || []} />
{BalancesList({ balances: settlement?.balances || [] })}
</div>
{/* By category */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
<CategoryBreakdown />
{CategoryBreakdown()}
</div>
</div>
)
@@ -682,6 +711,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)
}
// Called, not rendered — see MobileBody: an element here would remount the
// list on every keystroke in the search box.
function ExpenseRow({ e }: { e: BudgetItem }) {
const c = catMeta(e.category)
const Icon = c.Icon
@@ -775,7 +806,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
</div>
</div>
{canEdit && <RowActions onEdit={() => { setEditing(e); setModalOpen(true) }} onDelete={() => handleDelete(e.id)} deleteLabel={t('common.delete')} />}
{canEdit && RowActions({ onEdit: () => { setEditing(e); setModalOpen(true) }, onDelete: () => handleDelete(e.id), deleteLabel: t('common.delete') })}
</div>
)
}
@@ -817,6 +848,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
if (settlementError) return loadFailed()
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
return (
@@ -1017,7 +1049,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [name, setName] = useState(editing?.name || prefill?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
const [day, setDay] = useState(editing?.expense_date || localToday())
const [note, setNote] = useState(() => readUserNote(editing))
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : ''
@@ -1445,7 +1477,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 130px auto', gap: 8, alignItems: 'center' }}>
<input
type="text"
placeholder="Item name"
placeholder={t('costs.ticketItemName')}
value={item.name}
onChange={e => handleUpdateItemName(item.id, e.target.value)}
className="bg-surface-input border border-edge text-content"
@@ -1468,7 +1500,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>Splitting:</span>
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>{t('costs.ticketSplitting')}</span>
{people.map((p, pIdx) => {
const active = item.participants.has(p.id)
return (
@@ -1492,12 +1524,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
<button type="button" onClick={handleAddEmptyItem} className="border border-dashed border-edge text-content-muted" style={{ padding: '8px 12px', borderRadius: 10, background: 'none', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<Plus size={14} /> Add item
<Plus size={14} /> {t('costs.ticketAddItem')}
</button>
{ticketItems.length > 0 && (
<div className="bg-surface-secondary border border-edge" style={{ padding: 12, borderRadius: 10 }}>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Individual Shares Summary</div>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>{t('costs.ticketShares')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{people.map(p => {
const share = ticketInfo.shares[p.id] || 0
@@ -1532,7 +1564,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{sym(currency)}{(equalShares[p.id] || 0).toFixed(2)}
</span>
) : (
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>Excluded</span>
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>{t('costs.excluded')}</span>
)
) : (
on ? (
@@ -1557,9 +1589,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</span>
) : (
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
{customBalanced
? 'Split matches total'
: `Sum of splits: ${sym(currency)}${splitSum.toFixed(2)} of ${sym(currency)}${totalNum.toFixed(2)} (${(totalNum - splitSum) > 0 ? 'under by' : 'over by'} ${sym(currency)}${Math.abs(totalNum - splitSum).toFixed(2)})`}
{customBalanced
? t('costs.splitBalanced')
: t(totalNum - splitSum > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', {
sum: sym(currency) + splitSum.toFixed(2),
total: sym(currency) + totalNum.toFixed(2),
diff: sym(currency) + Math.abs(totalNum - splitSum).toFixed(2),
})}
</span>
)}
</div>
@@ -2,7 +2,12 @@ import { useState, useEffect } from 'react'
import { collabApi } from '../../api/client'
/* ── Link Preview ── */
const previewCache = {}
// linkPreview is trip-scoped on the server, so the cache key has to be too.
// Capped as well: a long chat session would otherwise keep every preview it ever
// rendered for as long as the tab lives.
const previewCache = new Map()
const cacheKey = (tripId: number, url: string): string => `${tripId} ${url}`
const MAX_CACHED_PREVIEWS = 200
interface LinkPreviewProps {
url: string
@@ -12,17 +17,23 @@ interface LinkPreviewProps {
}
export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
const [data, setData] = useState(previewCache[url] || null)
const [loading, setLoading] = useState(!previewCache[url])
const [data, setData] = useState(previewCache.get(cacheKey(tripId, url)) || null)
const [loading, setLoading] = useState(!previewCache.has(cacheKey(tripId, url)))
useEffect(() => {
if (previewCache[url]) return
const key = cacheKey(tripId, url)
const cached = previewCache.get(key)
if (cached) { setData(cached); setLoading(false); return }
let current = true
collabApi.linkPreview(tripId, url).then(d => {
previewCache[url] = d
if (previewCache.size >= MAX_CACHED_PREVIEWS) previewCache.delete(previewCache.keys().next().value)
previewCache.set(key, d)
if (!current) return
setData(d)
setLoading(false)
if (d?.title || d?.description || d?.image) onLoad?.()
}).catch(() => setLoading(false))
}).catch(() => { if (current) setLoading(false) })
return () => { current = false }
}, [url, tripId])
if (loading || !data || (!data.title && !data.description && !data.image)) return null
@@ -1,4 +1,4 @@
// FE-W4CCS-001 to FE-W4CCS-016
// FE-W4CCS-001 to FE-W4CCS-019
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import type { ChatReaction } from './CollabChat.types'
@@ -11,6 +11,7 @@ import { TwemojiImg } from './CollabChatTwemojiImg'
import { ReactionBadge } from './CollabChatReactionBadge'
import { UserAvatar } from './CollabNotesUserAvatar'
import { WebsiteThumbnail } from './CollabNotesWebsiteThumbnail'
import { LinkPreview } from './CollabChatLinkPreview'
function reaction(overrides: Partial<ChatReaction> = {}): ChatReaction {
return { emoji: '👍', count: 1, users: [{ id: 1, username: 'ada' }], ...overrides } as unknown as ChatReaction
@@ -147,13 +148,26 @@ describe('WebsiteThumbnail', () => {
expect(screen.getByText('example.com')).toBeInTheDocument()
})
it('FE-W4CCS-014: falls back to a link label for an unparseable url and a failing preview', async () => {
it('FE-W4CCS-014: keeps the domain chip when the preview request fails', async () => {
linkPreview.mockRejectedValue(new Error('blocked'))
render(<WebsiteThumbnail url="not a url" tripId={4} color="#000" />)
render(<WebsiteThumbnail url="https://blocked.example/a" tripId={4} color="#000" />)
expect(screen.getByText('link')).toBeInTheDocument()
expect(screen.getByText('blocked.example')).toBeInTheDocument()
await waitFor(() => expect(linkPreview).toHaveBeenCalled())
expect(screen.getByRole('link')).toHaveAttribute('title', 'not a url')
expect(screen.getByRole('link')).toHaveAttribute('title', 'https://blocked.example/a')
})
it('FE-W4CCS-017: a url that is not http(s) is shown but neither linked nor previewed', async () => {
const { unmount } = render(<WebsiteThumbnail url="javascript:alert(1)" tripId={4} color="#000" />)
expect(screen.queryByRole('link')).toBeNull()
expect(screen.getByTitle('javascript:alert(1)')).toBeInTheDocument()
unmount()
render(<WebsiteThumbnail url="not a url" tripId={4} color="#000" />)
expect(screen.getByText('link')).toBeInTheDocument()
expect(screen.queryByRole('link')).toBeNull()
expect(linkPreview).not.toHaveBeenCalled()
})
it('FE-W4CCS-015: caches per trip, because the preview endpoint is trip-scoped', async () => {
@@ -185,3 +199,34 @@ describe('WebsiteThumbnail', () => {
expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/b.png')
})
})
describe('LinkPreview', () => {
it('FE-W4CCS-018: caches per trip, because the preview endpoint is trip-scoped', async () => {
const shared = 'https://example.com/preview'
linkPreview.mockResolvedValue({ title: 'Trip 4', image: null })
const first = render(<LinkPreview url={shared} tripId={4} own={false} onLoad={undefined} />)
expect(await screen.findByText('Trip 4')).toBeInTheDocument()
first.unmount()
linkPreview.mockResolvedValue({ title: 'Trip 9', image: null })
render(<LinkPreview url={shared} tripId={9} own={false} onLoad={undefined} />)
expect(await screen.findByText('Trip 9')).toBeInTheDocument()
expect(linkPreview).toHaveBeenCalledTimes(2)
expect(linkPreview).toHaveBeenLastCalledWith(9, shared)
})
it('FE-W4CCS-019: a preview that resolves after the url changed is not shown', async () => {
let resolveFirst: (v: { title: string; image: string | null }) => void = () => {}
linkPreview.mockImplementationOnce(() => new Promise(res => { resolveFirst = res }))
const { rerender } = render(<LinkPreview url="https://slow.example/a" tripId={4} own={false} onLoad={undefined} />)
linkPreview.mockResolvedValue({ title: 'Second', image: null })
rerender(<LinkPreview url="https://fast.example/b" tripId={4} own={false} onLoad={undefined} />)
expect(await screen.findByText('Second')).toBeInTheDocument()
resolveFirst({ title: 'First', image: null })
await waitFor(() => expect(screen.queryByText('First')).toBeNull())
expect(screen.getByText('Second')).toBeInTheDocument()
})
})
@@ -271,6 +271,7 @@ describe('CollabNotes', () => {
const listener = calls[calls.length - 1][0];
act(() => {
listener({
tripId: 1,
type: 'collab:note:created',
note: {
id: 50, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null,
@@ -301,7 +302,7 @@ describe('CollabNotes', () => {
const calls = (addListener as ReturnType<typeof vi.fn>).mock.calls;
const listener = calls[calls.length - 1][0];
act(() => {
listener({ type: 'collab:note:deleted', noteId: 7 });
listener({ tripId: 1, type: 'collab:note:deleted', noteId: 7 });
});
await waitFor(() => expect(screen.queryByText('WS Delete')).not.toBeInTheDocument());
});
@@ -463,6 +464,7 @@ describe('CollabNotes', () => {
const listener = calls[calls.length - 1][0];
act(() => {
listener({
tripId: 1,
type: 'collab:note:updated',
note: {
id: 5, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null,
@@ -1390,7 +1392,7 @@ describe('CollabNotes details', () => {
await screen.findByText('Already here');
const handler = wsHandler();
await act(async () => {
handler({ type: 'collab:note:created', note: buildNote({ id: 4, title: 'Already here' }) });
handler({ tripId: 1, type: 'collab:note:created', note: buildNote({ id: 4, title: 'Already here' }) });
});
expect(screen.getAllByText('Already here')).toHaveLength(1);
});
@@ -1403,7 +1405,7 @@ describe('CollabNotes details', () => {
await screen.findByText('Second');
const handler = wsHandler();
await act(async () => {
handler({ type: 'collab:note:updated', note: { id: 2, title: 'Second renamed' } });
handler({ tripId: 1, type: 'collab:note:updated', note: { id: 2, title: 'Second renamed' } });
});
expect(await screen.findByText('Second renamed')).toBeInTheDocument();
expect(screen.getByText('First')).toBeInTheDocument();
@@ -1414,9 +1416,9 @@ describe('CollabNotes details', () => {
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Doomed');
const handler = wsHandler();
await act(async () => { handler({ type: 'collab:note:deleted' }); });
await act(async () => { handler({ tripId: 1, type: 'collab:note:deleted' }); });
expect(screen.getByText('Doomed')).toBeInTheDocument();
await act(async () => { handler({ type: 'collab:note:deleted', id: 9 }); });
await act(async () => { handler({ tripId: 1, type: 'collab:note:deleted', id: 9 }); });
await waitFor(() => expect(screen.queryByText('Doomed')).not.toBeInTheDocument());
});
@@ -1803,6 +1805,59 @@ describe('CollabNotes details', () => {
expect(screen.getByText('Older note')).toBeInTheDocument();
});
it('FE-W5CNT-030: a WebSocket event for another trip is ignored', async () => {
serveNotes({ notes: [] });
render(<CollabNotes {...defaultProps} />);
await screen.findByText('No notes yet');
const handler = wsHandler();
await act(async () => {
handler({ tripId: 2, type: 'collab:note:created', note: buildNote({ id: 60, title: 'Other trip note' }) });
});
expect(screen.queryByText('Other trip note')).not.toBeInTheDocument();
});
it('FE-W5CNT-031: a rejected rename still renames the rest and re-reads the list', async () => {
const user = userEvent.setup();
const puts: string[] = [];
serveNotesSequence([
{ notes: [buildNote({ id: 1, title: 'First note', category: 'OldCat' }), buildNote({ id: 2, title: 'Second note', category: 'OldCat' })] },
{ notes: [buildNote({ id: 1, title: 'First note', category: 'OldCat' }), buildNote({ id: 2, title: 'Second note', category: 'NewCat' })] },
]);
server.use(
http.put('/api/trips/1/collab/notes/1', () => {
puts.push('1');
return new HttpResponse(null, { status: 500 });
}),
http.put('/api/trips/1/collab/notes/2', () => {
puts.push('2');
return HttpResponse.json({ note: buildNote({ id: 2, title: 'Second note', category: 'NewCat' }) });
}),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('First note');
await user.click(screen.getByTitle('Manage Categories'));
await screen.findByText('Manage Categories', { selector: 'h3' });
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename')!;
await user.click(oldCat);
const editInput = screen.getByDisplayValue('OldCat');
await user.clear(editInput);
await user.type(editInput, 'NewCat');
await user.keyboard('{Enter}');
await user.click(screen.getByRole('button', { name: /^Save$/i }));
// The rejected note must not stop the second one, but the modal stays open —
// half a rename is not a saved rename.
await waitFor(() => expect(puts).toEqual(['1', '2']));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
// Re-read: the note the server refused is still shown under its old category.
const header = screen.getByText('Manage Categories', { selector: 'h3' }).parentElement!;
await user.click(within(header).getByRole('button'));
await waitFor(() => expect(screen.getAllByText('NewCat').length).toBeGreaterThan(0));
expect(screen.getAllByText('OldCat').length).toBeGreaterThan(0);
});
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' })] });
@@ -1819,4 +1874,36 @@ describe('CollabNotes details', () => {
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
expect(screen.getByText('Stubborn note')).toBeInTheDocument();
});
it('FE-W5CNT-032: a rejected category rename keeps the settings modal open and reports once', async () => {
const user = userEvent.setup();
serveNotes({
notes: [
buildNote({ id: 1, title: 'First note', category: 'OldCat' }),
buildNote({ id: 2, title: 'Second note', category: 'OldCat' }),
],
});
server.use(
http.put('/api/trips/1/collab/notes/:id', () => new HttpResponse(null, { status: 500 })),
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('First note');
await user.click(screen.getByTitle('Manage Categories'));
await screen.findByText('Manage Categories', { selector: 'h3' });
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename')!;
await user.click(oldCat);
const editInput = screen.getByDisplayValue('OldCat');
await user.clear(editInput);
await user.type(editInput, 'NewCat');
await user.keyboard('{Enter}');
await screen.findByText('NewCat');
await user.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
// Nothing was renamed, so the modal must not act like it was saved…
expect(screen.getByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
// …and both rejected writes are one message, not one each.
expect(addToast).toHaveBeenCalledTimes(1);
});
});
+39 -13
View File
@@ -89,6 +89,9 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
if (!tripId) return
const handler = (msg) => {
// The panel is not remounted on a trip change, so an event still in flight
// from the trip we just left must not land in this list.
if (String(msg?.tripId) !== String(tripId)) return
if (msg.type === 'collab:note:created' && msg.note) {
setNotes(prev => {
if (prev.some(n => n.id === msg.note.id)) return prev
@@ -145,12 +148,13 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
}
}, [tripId, toast, t])
const handleUpdateNote = useCallback(async (noteId, data) => {
const handleUpdateNote = useCallback(async (noteId, data, opts: { silent?: boolean } = {}) => {
let result
try {
result = await collabApi.updateNote(tripId, noteId, data)
} catch (err) {
toast.error(t('common.error'))
// A batch of writes reports once for the whole run instead of once per note.
if (!opts.silent) toast.error(t('common.error'))
throw err
}
const updated = result?.note || result
@@ -161,16 +165,28 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
}
}, [tripId, toast, t])
// A colour or a rename is N single-note writes; if one of them is rejected the
// rest still have to run, and the list has to be re-read so it stops showing a
// change the server never took. Reporting the failure is the caller's job.
const resyncNotes = useCallback(async () => {
try {
const fresh = await collabApi.getNotes(tripId)
setNotes(fresh?.notes || fresh || [])
} catch {}
}, [tripId])
const saveCategoryColors = useCallback(async (newMap) => {
let failed = 0
// Update notes with changed colors
for (const [cat, color] of Object.entries(newMap)) {
const notesInCat = notes.filter(n => n.category === cat)
if (notesInCat.length > 0 && categoryColors[cat] !== color) {
for (const n of notesInCat) {
await handleUpdateNote(n.id, { color })
try { await handleUpdateNote(n.id, { color }) } catch { failed++ }
}
}
}
if (failed > 0) await resyncNotes()
// Save all categories (including empty ones) to localStorage
const emptyCats = {}
for (const [cat, color] of Object.entries(newMap)) {
@@ -179,7 +195,23 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
}
}
saveEmptyCategories(emptyCats)
}, [categoryColors, notes, handleUpdateNote])
}, [categoryColors, notes, handleUpdateNote, resyncNotes])
const renameCategory = useCallback(async (oldName, newName) => {
// Update all notes with this category in DB
const toUpdate = notes.filter(n => n.category === oldName)
let failed = 0
for (const n of toUpdate) {
try { await handleUpdateNote(n.id, { category: newName }, { silent: true }) } catch { failed++ }
}
// The rest still had to run, but a partial rename is not a saved rename: the
// settings modal has to stay open on the rejection instead of closing on it.
if (failed > 0) {
await resyncNotes()
toast.error(t('common.error'))
throw new Error(`rename failed for ${failed} of ${toUpdate.length} notes`)
}
}, [notes, handleUpdateNote, resyncNotes, toast, t])
const handleEditSubmit = useCallback(async (data) => {
if (!editingNote) return
@@ -232,7 +264,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
notes, loading, showNewModal, setShowNewModal, editingNote, setEditingNote,
viewingNote, setViewingNote, previewFile, setPreviewFile, showSettings, setShowSettings,
activeCategory, setActiveCategory, categoryColors, getCategoryColor,
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
handleCreateNote, handleUpdateNote, saveCategoryColors, renameCategory, handleEditSubmit,
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
pendingDeleteNoteId, setPendingDeleteNoteId,
}
@@ -462,7 +494,7 @@ export default function CollabNotes(props: CollabNotesProps) {
loading, tripId, t, categories, categoryColors, getCategoryColor, notes,
viewingNote, showNewModal, editingNote, previewFile, showSettings,
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, renameCategory, handleUpdateNote,
handleDeleteNote, pendingDeleteNoteId, setPendingDeleteNoteId,
} = S
@@ -511,13 +543,7 @@ export default function CollabNotes(props: CollabNotesProps) {
categories={categories}
categoryColors={categoryColors}
onSave={saveCategoryColors}
onRenameCategory={async (oldName, newName) => {
// Update all notes with this category in DB
const toUpdate = notes.filter(n => n.category === oldName)
for (const n of toUpdate) {
await handleUpdateNote(n.id, { category: newName })
}
}}
onRenameCategory={renameCategory}
t={t}
/>
)}
@@ -48,9 +48,14 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
}
const handleSave = async () => {
// Apply renames to notes in DB
for (const [oldName, newName] of Object.entries(renames)) {
if (oldName !== newName) await onRenameCategory(oldName, newName)
// Apply renames to notes in DB. A rejected rename keeps the modal open — closing
// it would look like the rename went through; the caller has already reported it.
try {
for (const [oldName, newName] of Object.entries(renames)) {
if (oldName !== newName) await onRenameCategory(oldName, newName)
}
} catch {
return
}
await onSave(localColors)
onClose()
@@ -12,7 +12,7 @@ vi.mock('../../api/websocket', () => ({
import { render, screen, waitFor, fireEvent, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { http, HttpResponse, delay } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
@@ -234,7 +234,7 @@ describe('CollabPolls', () => {
// Get the WS listener that was registered
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
listener({ type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
listener({ tripId: 1, type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
await screen.findByText('Live poll?');
});
@@ -249,7 +249,7 @@ describe('CollabPolls', () => {
await screen.findByText('Best destination?');
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
listener({ type: 'collab:poll:deleted', pollId: 3 });
listener({ tripId: 1, type: 'collab:poll:deleted', pollId: 3 });
await waitFor(() =>
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
@@ -658,8 +658,8 @@ describe('CollabPolls details', () => {
const handler = wsHandler();
await act(async () => {
handler({});
handler({ type: 'collab:poll:deleted' });
handler({ type: 'collab:poll:created', poll: { id: 40, question: 'Best destination?' } });
handler({ tripId: 1, type: 'collab:poll:deleted' });
handler({ tripId: 1, type: 'collab:poll:created', poll: { id: 40, question: 'Best destination?' } });
});
expect(screen.getAllByText('Best destination?')).toHaveLength(1);
});
@@ -671,22 +671,52 @@ describe('CollabPolls details', () => {
const handler = wsHandler();
await act(async () => {
handler({ type: 'collab:poll:voted', poll: buildPoll({ id: 41, question: 'Voted live' }) });
handler({ tripId: 1, type: 'collab:poll:voted', poll: buildPoll({ id: 41, question: 'Voted live' }) });
});
expect(await screen.findByText('Voted live')).toBeInTheDocument();
expect(screen.getByText('Other poll')).toBeInTheDocument();
await act(async () => {
handler({ type: 'collab:poll:closed', poll: { id: 41 } });
handler({ tripId: 1, type: 'collab:poll:closed', poll: { id: 41 } });
});
await waitFor(() => expect(screen.getAllByText('Closed')).toHaveLength(2));
await act(async () => {
handler({ type: 'collab:poll:deleted', poll: { id: 42 } });
handler({ tripId: 1, type: 'collab:poll:deleted', poll: { id: 42 } });
});
await waitFor(() => expect(screen.queryByText('Other poll')).not.toBeInTheDocument());
});
it('FE-W5CPL-027: a WebSocket event for another trip is ignored', async () => {
servePolls({ polls: [] });
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab.polls.empty/i);
const handler = wsHandler();
await act(async () => {
handler({ tripId: 2, type: 'collab:poll:created', poll: buildPoll({ id: 88, question: 'Other trip poll?' }) });
});
expect(screen.queryByText('Other trip poll?')).not.toBeInTheDocument();
});
it('FE-W5CPL-028: a slow load for the trip we left does not overwrite the new one', async () => {
server.use(
http.get('/api/trips/1/collab/polls', async () => {
await delay(80);
return HttpResponse.json({ polls: [buildPoll({ id: 1, question: 'Trip one poll?' })] });
}),
http.get('/api/trips/2/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ id: 2, question: 'Trip two poll?' })] }),
),
);
const { rerender } = render(<CollabPolls {...defaultProps} />);
rerender(<CollabPolls tripId={2} currentUser={currentUser} />);
await screen.findByText('Trip two poll?');
await act(async () => { await delay(150); });
expect(screen.queryByText('Trip one poll?')).not.toBeInTheDocument();
expect(screen.getByText('Trip two poll?')).toBeInTheDocument();
});
it('FE-W5CPL-026: a poll with a live deadline starts the countdown ticker', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
+13 -3
View File
@@ -359,15 +359,25 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
const [showForm, setShowForm] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
collabApi.getPolls(tripId).then(data => {
setPolls(Array.isArray(data) ? data : data.polls || [])
}).catch(() => {}).finally(() => setLoading(false))
if (!cancelled) setPolls(Array.isArray(data) ? data : data.polls || [])
}).catch(() => {
if (!cancelled) setPolls([])
}).finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [tripId])
// WebSocket
useEffect(() => {
const handler = (msg) => {
if (!msg?.type) return
// The panel is not remounted on a trip change, so an event still in flight
// from the trip we just left must not land in this list.
if (String(msg.tripId) !== String(tripId)) return
if (msg.type === 'collab:poll:created' && msg.poll) {
setPolls(prev => prev.some(p => p.id === msg.poll.id) ? prev : [msg.poll, ...prev])
}
@@ -384,7 +394,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
}
addListener(handler)
return () => removeListener(handler)
}, [])
}, [tripId])
const handleCreate = useCallback(async (data) => {
try {
@@ -3,21 +3,25 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import WhatsNextWidget from './WhatsNextWidget'
import { afterEach, beforeEach, describe, it, expect } from 'vitest'
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
// Dynamic date helpers
const today = new Date().toISOString().split('T')[0]
// Dynamic date helpers, in local calendar dates like the widget itself uses
function localDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const today = localDate(new Date())
function getFutureDate(daysAhead: number): string {
const d = new Date()
d.setDate(d.getDate() + daysAhead)
return d.toISOString().split('T')[0]
return localDate(d)
}
function getPastDate(daysBack: number): string {
const d = new Date()
d.setDate(d.getDate() - daysBack)
return d.toISOString().split('T')[0]
return localDate(d)
}
const tomorrow = getFutureDate(1)
@@ -258,6 +262,29 @@ describe('WhatsNextWidget', () => {
expect(screen.getByText('Lunch')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-016: keeps remaining events for the local day west of UTC after the UTC date rolls over', () => {
const prevTz = process.env.TZ
process.env.TZ = 'America/New_York'
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
// 20:00 in New York, already the next calendar day in UTC
vi.setSystemTime(new Date('2026-08-24T00:00:00Z'))
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: '2026-08-23', title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(80, { name: 'Late Show', place_time: '21:00' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('Late Show')).toBeInTheDocument()
expect(screen.getByText(/today/i)).toBeInTheDocument()
} finally {
vi.useRealTimers()
if (prevTz === undefined) delete process.env.TZ
else process.env.TZ = prevTz
}
})
it('FE-COMP-WHATSNEXT-015: today past-time event is excluded', () => {
// If it's not midnight, a past-time event today should not appear
const now = new Date()
@@ -5,6 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { MapPin, Clock, Users, Sparkles } from 'lucide-react'
import EmptyState from '../shared/EmptyState'
import { localToday } from '../Planner/today'
function formatTime(timeStr, is12h) {
if (!timeStr) return ''
@@ -19,9 +20,10 @@ function formatTime(timeStr, is12h) {
function formatDayLabel(date, t, locale) {
const now = new Date()
const nowDate = now.toISOString().split('T')[0]
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
// Day dates are plain calendar strings, so "today"/"tomorrow" have to be
// compared against the local calendar day, not the UTC one.
const nowDate = localToday(now)
const tomorrowDate = localToday(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1))
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
@@ -47,7 +49,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
const upcoming = useMemo(() => {
const now = new Date()
const nowDate = now.toISOString().split('T')[0]
const nowDate = localToday(now)
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
const items = []
@@ -172,6 +172,21 @@ describe('ListEditorModal', () => {
expect(actions.createCollection).not.toHaveBeenCalled()
})
it('FE-COMP-LISTEDITOR-026: a second Enter while the create is in flight does not make a second list', async () => {
let release: (value: { id: number; owner_id: number; name: string }) => void = () => {}
actions.createCollection.mockReturnValueOnce(new Promise(resolve => { release = resolve }))
const { props } = setup()
const nameInput = screen.getByPlaceholderText('e.g. Tokyo 2025')
fireEvent.change(nameInput, { target: { value: 'Lisbon' } })
fireEvent.keyDown(nameInput, { key: 'Enter' })
await waitFor(() => expect(actions.createCollection).toHaveBeenCalledTimes(1))
fireEvent.keyDown(nameInput, { key: 'Enter' })
release({ id: 77, owner_id: 1, name: 'Lisbon' })
await waitFor(() => expect(props.onClose).toHaveBeenCalled())
expect(actions.createCollection).toHaveBeenCalledTimes(1)
expect(props.onCreated).toHaveBeenCalledTimes(1)
})
it('FE-COMP-LISTEDITOR-011: picking a swatch changes the saved colour', async () => {
setup()
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
@@ -122,7 +122,9 @@ export default function ListEditorModal({ target, onClose, onCreated, onRequestD
const save = async () => {
const trimmed = name.trim()
if (!trimmed) return
// Enter fires while the create is still in flight, and `createdId` is only
// set once it comes back — without this the second one creates a second list.
if (!trimmed || saving) return
// Normalise + keep only links with a url; drop blank rows.
const cleanLinks = links
.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) }))
@@ -1,7 +1,7 @@
// FE-W4LBX-001 to FE-W4LBX-019
// FE-W4LBX-001 to FE-W4LBX-020
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { TripFile } from '../../types'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render'
const getAuthUrl = vi.fn(async (url: string, _kind: string) => `${url}?token=abc`)
const openFile = vi.fn(async (_url: string, _name: string) => {})
@@ -222,6 +222,23 @@ describe('ImageLightbox', () => {
expect(first).not.toHaveBeenCalled()
})
it('FE-W4LBX-020: a mint that lands after the gallery moved on does not paint', async () => {
const pending: Record<string, (url: string) => void> = {}
getAuthUrl.mockImplementation((url: string) => new Promise<string>(resolve => { pending[url] = resolve }))
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download'))
fireEvent.keyDown(window, { key: 'ArrowRight' })
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/b.jpg', 'download'))
// The token for the image we already left resolves last.
await act(async () => {
pending['/f/b.jpg']('/f/b.jpg?token=b')
pending['/f/a.jpg']('/f/a.jpg?token=a')
})
expect(container.querySelector('img[alt="b.jpg"]')).toHaveAttribute('src', '/f/b.jpg?token=b')
})
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.
@@ -27,7 +27,12 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
setImgSrc('')
// Images use a one-shot signed URL; a video must use the plain same-origin
// URL (cookie auth) so its many Range requests all authenticate (#823).
if (file && !isVideo(file.mime_type)) getAuthUrl(file.url, 'download').then(setImgSrc)
if (!file || isVideo(file.mime_type)) return
// Arrowing through the gallery leaves several mints in flight; only the one for
// the file still on screen may paint.
let current = true
getAuthUrl(file.url, 'download').then(u => { if (current) setImgSrc(u) }).catch(() => {})
return () => { current = false }
}, [file?.url, file?.mime_type])
const goPrev = () => setIndex(i => Math.max(0, i - 1))
@@ -586,11 +586,11 @@ describe('EntryEditor', () => {
expect(stripImg.getAttribute('src')).toBe('/api/photos/100/original')
})
it('FE-JRN-EDITOR-029: reorders photos locally even when persisting the order fails', async () => {
it('FE-JRN-EDITOR-029: rolls the order back when persisting it fails', async () => {
let attempts = 0
server.use(http.patch('/api/journeys/photos/:id', () => {
attempts += 1
return new HttpResponse(null, { status: 500 })
return HttpResponse.json({ error: 'sort rejected' }, { status: 500 })
}))
const user = userEvent.setup()
const { container } = mountEditor(buildEntry({ id: 10, photos: [buildPhoto(100), buildPhoto(101)] }))
@@ -598,6 +598,27 @@ describe('EntryEditor', () => {
await user.click(screen.getByRole('button', { name: 'Make 1st' }))
await waitFor(() => expect(attempts).toBe(2))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('sort rejected', 'error', undefined))
const order = Array.from(container.querySelectorAll('.w-20.h-20 img')).map(i => i.getAttribute('src'))
expect(order).toEqual(['/api/photos/100/thumbnail', '/api/photos/101/thumbnail'])
})
it('FE-JRN-EDITOR-047: a partly accepted order keeps what the server took', async () => {
const patched: number[] = []
server.use(http.patch('/api/journeys/photos/:id', ({ params }) => {
const id = Number(params.id)
patched.push(id)
if (id === 100) return HttpResponse.json({ error: 'sort rejected' }, { status: 500 })
return HttpResponse.json({ success: true })
}))
const user = userEvent.setup()
const { container } = mountEditor(buildEntry({ id: 10, photos: [buildPhoto(100), buildPhoto(101)] }))
await user.click(screen.getByRole('button', { name: 'Make 1st' }))
await waitFor(() => expect(patched).toEqual([101, 100]))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('sort rejected', 'error', undefined))
// 101 is first on the server now; snapping the strip back would hide that.
const order = Array.from(container.querySelectorAll('.w-20.h-20 img')).map(i => i.getAttribute('src'))
expect(order).toEqual(['/api/photos/101/thumbnail', '/api/photos/100/thumbnail'])
})
@@ -865,6 +886,32 @@ describe('EntryEditor', () => {
expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: null }))
})
it('FE-JRN-EDITOR-044: reports a rejected save and keeps the editor open', async () => {
const user = userEvent.setup()
const { onSave, onDone } = mountEditor(buildEntry({ id: 10, title: 'Old' }))
onSave.mockRejectedValueOnce(new Error('server said no'))
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('server said no', 'error', undefined))
expect(onDone).not.toHaveBeenCalled()
expect(screen.getByRole('heading', { name: 'Edit Entry' })).toBeInTheDocument()
})
it('FE-JRN-EDITOR-046: gives the preview blob URLs back when the editor closes', async () => {
const revoke = vi.spyOn(URL, 'revokeObjectURL')
const { container, unmount } = mountEditor(buildEntry())
fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, {
target: { files: [new File(['a'], 'a.jpg', { type: 'image/jpeg' })] },
})
await waitFor(() => expect(container.querySelector('img[src="blob:preview"]')).toBeInTheDocument())
unmount()
expect(revoke).toHaveBeenCalledWith('blob:preview')
revoke.mockRestore()
})
it('FE-JRN-EDITOR-043: the camera route is its own input, so the library picker survives', () => {
const { container } = mountEditor(buildEntry({ id: 10 }))
@@ -1,9 +1,9 @@
import { useEffect, useState, useRef } from 'react'
import { useEffect, useMemo, useState, useRef } from 'react'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
import { useTranslation } from '../../i18n'
import { journeyApi, mapsApi, addonsApi } from '../../api/client'
import { journeyApi, mapsApi, addonsApi, memoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
import { getApiErrorMessage } from '../../types'
@@ -52,6 +52,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([])
// Minting the preview URL inline in the JSX would hand out a fresh blob on
// every keystroke in the story field and never give one back.
const pendingUrls = useMemo(() => pendingFiles.map(f => URL.createObjectURL(f)), [pendingFiles])
useEffect(() => () => { pendingUrls.forEach(u => URL.revokeObjectURL(u)) }, [pendingUrls])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false)
const [photoTab, setPhotoTab] = useState<'upload' | 'gallery' | 'external'>('upload')
@@ -103,8 +107,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const connected: { id: string; name: string }[] = []
for (const provider of enabled) {
try {
const response = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' })
if (response.ok && (await response.json()).connected) connected.push({ id: provider.id, name: provider.name })
if ((await memoriesApi.status(provider.id)).connected) connected.push({ id: provider.id, name: provider.name })
} catch {}
}
setAvailableProviders(connected)
@@ -186,6 +189,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
setPendingProviderGroups([])
}
onDone()
} catch (err) {
// Neither the page callback nor journeyStore toasts, so without this the
// whole entry just fails to save with no sign of it.
toast.error(getApiErrorMessage(err, t('journey.settings.saveFailed')))
return
} finally {
setSaving(false)
}
@@ -436,13 +444,25 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<button
onClick={e => {
e.stopPropagation()
setPhotos(prev => {
const next = [...prev]
const [moved] = next.splice(idx, 1)
next.unshift(moved)
next.forEach((ph, i) => { journeyApi.updatePhoto(ph.id, { sort_order: i }).catch(() => {}) })
return next
})
const prevOrder = photos
const next = [...photos]
const [moved] = next.splice(idx, 1)
next.unshift(moved)
setPhotos(next)
// The order is shared: other members, the share view and the
// PDF all read it, so a rejected write has to go back. Every
// write is awaited first — snapping back on the first
// rejection would do it while the rest are still landing.
void (async () => {
const results = await Promise.allSettled(next.map((ph, i) => journeyApi.updatePhoto(ph.id, { sort_order: i })))
const rejected = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[]
if (rejected.length === 0) return
toast.error(getApiErrorMessage(rejected[0].reason, t('common.error')))
// Only a run where nothing landed can be put back: with a
// partial one the server already holds part of the new
// order, and hiding that would be the worse lie.
if (rejected.length === results.length) setPhotos(prevOrder)
})()
}}
className="absolute bottom-0.5 left-0.5 px-1.5 py-0.5 rounded bg-black/60 text-white text-[8px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity"
>
@@ -468,7 +488,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
))}
{pendingFiles.map((f, i) => (
<div key={`pending-${i}`} className="w-20 h-20 rounded-xl overflow-hidden relative group">
<img src={URL.createObjectURL(f)} className="w-full h-full object-cover" alt="" />
<img src={pendingUrls[i]} className="w-full h-full object-cover" alt="" />
<button
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
@@ -4,7 +4,7 @@ import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { useJourneyStore } from '../../store/journeyStore'
import { useTranslation } from '../../i18n'
import { journeyApi, addonsApi } from '../../api/client'
import { journeyApi, addonsApi, memoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
@@ -42,11 +42,8 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
const connected: { id: string; name: string }[] = []
for (const p of enabledProviders) {
try {
const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' })
if (res.ok) {
const status = await res.json()
if (status.connected) connected.push({ id: p.id, name: p.name })
}
const status = await memoriesApi.status(p.id)
if (status.connected) connected.push({ id: p.id, name: p.name })
} catch {}
}
setAvailableProviders(connected)
@@ -1,6 +1,7 @@
import { useEffect, useState, useRef, useMemo } from 'react'
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { memoriesApi } from '../../api/client'
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
@@ -64,21 +65,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
setSearchTo(to)
setSearchPage(page)
try {
const res = await fetch(`/api/integrations/memories/${provider}/search`, {
method: 'POST', credentials: 'include', signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to, page, size: 50 }),
})
if (res.ok) {
const data = await res.json()
const assets = data.assets || []
setPhotos(prev => append ? [...prev, ...assets] : assets)
setHasMore(!!data.hasMore)
} else {
setHasMore(false)
}
} catch (e: any) {
if (e.name !== 'AbortError') setHasMore(false)
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal)
const assets = data.assets || []
setPhotos(prev => append ? [...prev, ...assets] : assets)
setHasMore(!!data.hasMore)
} catch {
// A cancelled request is about to be replaced by the next one, so leave
// its paging state alone.
if (!signal.aborted) setHasMore(false)
}
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
}
@@ -94,17 +88,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
setPhotos([])
setHasMore(false)
try {
const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : ''
const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal })
if (res.ok) setPhotos((await res.json()).assets || [])
} catch (e: any) { if (e.name !== 'AbortError') {} }
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [])
} catch { /* ignore */ }
if (!signal.aborted) setLoading(false)
}
const loadAlbums = async () => {
try {
const res = await fetch(`/api/integrations/memories/${provider}/albums`, { credentials: 'include' })
if (res.ok) setAlbums((await res.json()).albums || [])
setAlbums((await memoriesApi.albums(provider)).albums || [])
} catch {}
}
@@ -22,6 +22,7 @@ beforeEach(() => {
writeText.mockClear()
window.__addToast = toastSpy
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true })
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true, writable: true })
})
afterEach(() => {
@@ -37,6 +38,18 @@ describe('JourneyShareSection', () => {
expect(screen.getByText('Public Share')).toBeInTheDocument()
})
it('FE-JRN-SHARE-001b: renders nothing at all when the link is not this user to manage', async () => {
// Anyone but the owner gets 403 on the read, and would be refused on the
// create too - so the section stays out of the dialog rather than offering
// a button that cannot work.
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'Not allowed' }, { status: 403 })))
const { container } = render(<JourneyShareSection journeyId={JOURNEY_ID} />)
await waitFor(() => expect(container).toBeEmptyDOMElement())
expect(screen.queryByText('Public Share')).not.toBeInTheDocument()
})
it('FE-JRN-SHARE-002: renders nothing while the share link is still loading', async () => {
server.use(http.get('/api/journeys/7/share-link', async () => {
await delay(30)
@@ -99,7 +112,7 @@ describe('JourneyShareSection', () => {
act(() => { copyBtn.click() })
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/public/journey/tok-abc`)
expect(screen.getByRole('button', { name: 'Copied!' })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'Copied!' })).toBeInTheDocument()
act(() => { vi.advanceTimersByTime(2100) })
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument()
@@ -179,4 +192,23 @@ describe('JourneyShareSection', () => {
expect(await screen.findByRole('button', { name: /create share link/i })).toBeInTheDocument()
})
// A self-hosted install served over plain HTTP has no navigator.clipboard, and the
// unguarded call used to throw before the button ever showed feedback.
it('FE-JRN-SHARE-012: copies through execCommand when the clipboard API is unavailable', async () => {
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })))
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true, writable: true })
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true })
const execCommand = vi.fn(() => true)
Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true })
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
const copyBtn = await screen.findByRole('button', { name: 'Copy' })
act(() => { copyBtn.click() })
await waitFor(() => expect(execCommand).toHaveBeenCalledWith('copy'))
expect(await screen.findByRole('button', { name: 'Copied!' })).toBeInTheDocument()
// The temporary textarea is removed again.
expect(document.querySelector('textarea')).toBeNull()
})
})
@@ -3,16 +3,27 @@ import { Link, List, Grid, MapPin, Check } from 'lucide-react'
import { journeyApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { copyText } from '../../utils/clipboard'
export default function JourneyShareSection({ journeyId }: { journeyId: number }) {
const { t } = useTranslation()
const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
/** The share token is the owner's to manage; a contributor is refused. */
const [manageable, setManageable] = useState(true)
const toast = useToast()
useEffect(() => {
journeyApi.getShareLink(journeyId).then(d => setLink(d.link || null)).catch(() => {}).finally(() => setLoading(false))
journeyApi.getShareLink(journeyId)
.then(d => setLink(d.link || null))
.catch((err: { response?: { status?: number } }) => {
// A 403 is the server saying this is not yours to manage. Showing the
// section anyway would offer an editor a "create link" button that is
// refused the moment they press it.
if (err?.response?.status === 403) setManageable(false)
})
.finally(() => setLoading(false))
}, [journeyId])
const createLink = async () => {
@@ -42,13 +53,13 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : ''
const copyLink = () => {
navigator.clipboard.writeText(shareUrl)
const copyLink = async () => {
if (!(await copyText(shareUrl))) return
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (loading) return null
if (loading || !manageable) return null
return (
<div>
@@ -1696,6 +1696,15 @@ describe('MapViewGL', () => {
expect(glMap.remove).toHaveBeenCalled()
})
it('FE-COMP-MAPVIEWGL-069: unmounting lets go of the window debug handle', async () => {
const { unmount } = render(<MapViewGL places={[]} fitKey={1} />)
await act(async () => {})
expect((window as any).__trek_map).toBe(glMap)
unmount()
// Otherwise every style switch keeps its torn-down map reachable from window.
expect((window as any).__trek_map).toBeUndefined()
})
it('FE-COMP-MAPVIEWGL-059: pan and zoom report the visible bbox for the POI explore pill', async () => {
const onViewportChange = vi.fn()
render(<MapViewGL places={[]} fitKey={1} onViewportChange={onViewportChange} />)
+5
View File
@@ -930,6 +930,11 @@ export function MapViewGL({
}
try { map.remove() } catch { /* noop */ }
mapRef.current = null
// Drop the debug handle too, or a style switch keeps every torn-down map
// (canvas and sources included) alive for the rest of the page's life. The
// identity check leaves a freshly built map alone if this cleanup runs late.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((window as any).__trek_map === map) delete (window as any).__trek_map
setMapReady(false)
}
}, [glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality]) // rebuild on provider/style changes only
+12
View File
@@ -300,6 +300,18 @@ describe('downloadTripPDF', () => {
expect(iframe!.srcdoc).toContain('cover.jpg')
})
it('FE-COMP-TRIPPDF-023: a cover url cannot close url() and add a second declaration', async () => {
// cover_image is a free string on the write path, and the style attribute is decoded
// before the CSS is parsed, so the quote and the paren have to be encoded.
const hostile = "http://host/a.jpg');background-image:url('http://elsewhere/leak.jpg"
await downloadTripPDF({ ...richArgs, trip: { ...richArgs.trip, cover_image: hostile } as any })
const iframe = getIframe()
const style = /<div class="cover-bg" style="([^"]*)"/.exec(iframe!.srcdoc)![1]
expect(style.split('url(')).toHaveLength(2)
expect(style.match(/'/g)).toHaveLength(2)
})
it('FE-COMP-TRIPPDF-015: renders accommodation section when accommodations exist', async () => {
server.use(
http.get('/api/trips/:id/accommodations', () =>
+8 -1
View File
@@ -81,6 +81,13 @@ function escHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
// The style attribute is decoded back into raw quotes before the CSS is parsed, so
// escHtml alone cannot stop a cover_image from closing the url() and appending its own
// declaration. Percent-encoding is transparent to the fetch.
function cssUrl(url) {
return String(url).replace(/["'()\\\s]/g, c => '%' + c.charCodeAt(0).toString(16).padStart(2, '0'))
}
function absUrl(url) {
if (!url) return null
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
@@ -703,7 +710,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
<!-- Cover -->
<div class="cover">
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(cssUrl(coverImg))}')"></div>` : ''}
<div class="cover-dim"></div>
<div class="cover-brand"><img src="${absUrl('/logo-light.svg')}" style="height:28px;opacity:0.5;" /></div>
<div class="cover-body">
@@ -229,6 +229,33 @@ describe('KategorieGruppe — bulk actions', () => {
await waitFor(() => expect(puts).toEqual([1]))
})
it('FE-W5CAT-011a: Check All sends the items together, not one round trip after another', async () => {
const started: number[] = []
let release = () => {}
const gate = new Promise<void>(resolve => { release = resolve })
server.use(
http.put('/api/trips/1/packing/:itemId', async ({ params }) => {
started.push(Number(params.itemId))
// The first item only answers once the second one has been sent, so a
// serialised loop would never get its second request out.
if (started.length === 1) await gate
return HttpResponse.json({ item: buildPackingItem({ id: Number(params.itemId) }) })
}),
)
const { container } = setup({
items: [
buildPackingItem({ id: 1, name: 'Tent', checked: 0 }),
buildPackingItem({ id: 2, name: 'Rope', checked: 0 }),
],
})
openCategoryMenu(container)
fireEvent.click(screen.getByText('Check All'))
await waitFor(() => expect(started).toHaveLength(2))
release()
})
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 })] })
@@ -99,16 +99,13 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
}
// togglePackingItem rolls its own optimistic update back and reports the
// failure itself, so the bulk actions just drive it item by item.
// failure itself, so the bulk actions just drive it item by item. They go out
// together: serialised, a long list costs one round trip per item.
const handleCheckAll = async () => {
for (const item of Array.from(items)) {
if (!item.checked) await togglePackingItem(tripId, item.id, true)
}
await Promise.all(items.filter(i => !i.checked).map(i => togglePackingItem(tripId, i.id, true)))
}
const handleUncheckAll = async () => {
for (const item of Array.from(items)) {
if (item.checked) await togglePackingItem(tripId, item.id, false)
}
await Promise.all(items.filter(i => i.checked).map(i => togglePackingItem(tripId, i.id, false)))
}
const handleDeleteAll = async () => {
await onDeleteAll(items)
@@ -1,4 +1,4 @@
// FE-PLANNER-ADDRIN-001 to FE-PLANNER-ADDRIN-014
// FE-PLANNER-ADDRIN-001 to FE-PLANNER-ADDRIN-015
import { useState } from 'react';
import { delay, http, HttpResponse } from 'msw';
import userEvent from '@testing-library/user-event';
@@ -206,6 +206,28 @@ describe('AddressInput', () => {
expect(input).toHaveValue('Sacher');
});
it('FE-PLANNER-ADDRIN-015: a slow earlier search cannot overwrite the newer results', async () => {
// The debounce only cancels the timer; a request already on its way keeps
// going, and this one answers last.
server.use(http.post('/api/maps/search', async ({ request }) => {
const { query } = await request.json() as { query: string };
if (query === 'Sacher') { await delay(600); return HttpResponse.json({ places: [HOTEL] }); }
return HttpResponse.json({ places: [CAFE] });
}));
render(<Host />);
const input = screen.getByPlaceholderText('Street and number');
fireEvent.change(input, { target: { value: 'Sacher' } });
await settle(350);
fireEvent.change(input, { target: { value: 'Cafe Central' } });
expect(await screen.findByText('Cafe Central')).toBeInTheDocument();
// Now let the first request land.
await settle(500);
expect(screen.getByText('Cafe Central')).toBeInTheDocument();
expect(screen.queryByText('Hotel Sacher')).not.toBeInTheDocument();
});
it('FE-PLANNER-ADDRIN-014: a mousedown outside closes the list and a failing search empties it', async () => {
const user = userEvent.setup();
server.use(searchRoute([HOTEL]));
+11 -3
View File
@@ -22,6 +22,9 @@ export default function AddressInput({ value, onChange, placeholder, className }
const [loading, setLoading] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Clearing the timer does nothing to a request that is already out, and
// mapsApi.search takes no signal, so results are matched by ticket instead.
const reqIdRef = useRef(0)
useEffect(() => {
const handler = (e: MouseEvent) => {
@@ -38,23 +41,28 @@ export default function AddressInput({ value, onChange, placeholder, className }
const search = (text: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
const trimmed = text.trim()
if (trimmed.length < 3) { setResults([]); setLoading(false); return }
// Same reason as in pick(): a request for the longer text may still land.
if (trimmed.length < 3) { reqIdRef.current++; setResults([]); setLoading(false); return }
debounceRef.current = setTimeout(async () => {
const myReq = ++reqIdRef.current
setLoading(true)
try {
const data = await mapsApi.search(trimmed, locale)
if (myReq !== reqIdRef.current) return
setResults(data.places || [])
setHighlight(-1)
} catch {
setResults([])
if (myReq === reqIdRef.current) setResults([])
} finally {
setLoading(false)
if (myReq === reqIdRef.current) setLoading(false)
}
}, 320)
}
const pick = (r: any) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
// A response still on its way must not repopulate the list behind the pick.
reqIdRef.current++
onChange(r.address || r.name || '')
setOpen(false)
setResults([])
@@ -1,4 +1,4 @@
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-079
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-080
import React from 'react';
import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -342,6 +342,32 @@ describe('DayDetailPanel', () => {
expect(buttons.length).toBeGreaterThanOrEqual(2);
});
it('FE-PLANNER-DAYDETAIL-080: a failing accommodation edit keeps the picker open and says so', async () => {
const addToast = vi.fn();
window.__addToast = addToast;
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: null, confirmation: null,
}],
}),
),
http.put('/api/trips/1/accommodations/1', () => HttpResponse.json({ error: 'Stay overlaps' }, { status: 400 })),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Grand Hotel');
// The pencil beside the stay opens the picker in edit mode.
await userEvent.click(document.querySelector('.lucide-pencil')!.closest('button')!);
const picker = await waitFor(() => document.body.querySelector('[style*="z-index: 99999"]') as HTMLElement);
await userEvent.click(within(picker).getByText('Save'));
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Stay overlaps', 'error', undefined));
// The picker stays put so the entered values are not lost.
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-022: accommodation edit/remove buttons hidden when canEditDays=false', async () => {
// Use regular user + restrict day_edit to admin only
const regularUser = buildUser({ id: 999, role: 'user' });
@@ -13,6 +13,7 @@ import { useTripStore } from '../../store/tripStore'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
@@ -492,6 +493,44 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
days, locale, hotelForm, setHotelForm, categories, hotelCategoryFilter, setHotelCategoryFilter, places,
handleSelectPlace, accommodation, tripId, day, setAccommodations, setDayAccommodations, setAccommodation,
handleSaveAccommodation, onAccommodationChange }: any) {
const toast = useToast()
// Saving the picker. The edit branch stays here rather than in useDayDetail
// because it also refreshes the panel's own accommodation state; it has to say
// what went wrong when the write fails, otherwise the picker just sits there
// with the Save button doing nothing.
const saveHotelPicker = async () => {
if (showHotelPicker !== 'edit' || !accommodation) {
await handleSaveAccommodation()
return
}
try {
await accommodationsApi.update(tripId, accommodation.id, {
place_id: hotelForm.place_id,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null,
check_in_end: hotelForm.check_in_end || null,
check_out: hotelForm.check_out || null,
confirmation: hotelForm.confirmation || null,
})
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
// Reload
const d = await accommodationsApi.list(tripId)
const all = d.accommodations || []
setAccommodations(all)
setDayAccommodations(all.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
const acc = all.find(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false)
setAccommodation(acc || null)
onAccommodationChange?.()
} catch (err: unknown) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
toast.error(message || t('common.unknownError'))
}
}
return (
<>
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
@@ -635,35 +674,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
<button onClick={() => setShowHotelPicker(false)} style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button onClick={async () => {
if (showHotelPicker === 'edit' && accommodation) {
// Update existing
await accommodationsApi.update(tripId, accommodation.id, {
place_id: hotelForm.place_id,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null,
check_in_end: hotelForm.check_in_end || null,
check_out: hotelForm.check_out || null,
confirmation: hotelForm.confirmation || null,
})
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
// Reload
accommodationsApi.list(tripId).then(d => {
const all = d.accommodations || []
setAccommodations(all)
setDayAccommodations(all.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
const acc = all.find(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false)
setAccommodation(acc || null)
})
onAccommodationChange?.()
} else {
await handleSaveAccommodation()
}
}} disabled={!hotelForm.place_id} style={{
<button onClick={() => { void saveHotelPicker() }} disabled={!hotelForm.place_id} style={{
padding: '7px 20px', borderRadius: 8, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
background: hotelForm.place_id ? 'var(--text-primary)' : 'var(--bg-tertiary)',
color: hotelForm.place_id ? 'var(--bg-card)' : 'var(--text-faint)',
@@ -2400,6 +2400,25 @@ describe('DayPlanSidebar', () => {
expect(stored!.day_plan_position).toBe(positions[1].day_plan_position)
})
it('FE-PLANNER-DAYPLAN-204: a rejected slot write puts the bookings back where the server has them', async () => {
const { reservationsApi } = await import('../../api/client')
vi.mocked(reservationsApi.updatePositions).mockRejectedValueOnce(new Error('offline'))
const place = buildPlace({ id: 1, name: 'Cafe', place_time: '08:00' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place })
const train = buildReservation({ id: 302, type: 'train', title: 'Early train', reservation_time: '2025-06-01T06:00:00', day_id: 10 })
seedStore(useTripStore, { reservations: [train] })
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [a] }, reservations: [train],
})} />)
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled())
// The optimistic slot is rolled back, so the next mount derives it again
// instead of showing an order only this tab knows about.
await waitFor(() => {
expect(useTripStore.getState().reservations.find(r => r.id === 302)!.day_plan_position).toBeNull()
})
})
it('FE-PLANNER-DAYPLAN-115: a multi-day cruise labels its start, middle and end days', () => {
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
@@ -2595,6 +2614,33 @@ describe('DayPlanSidebar', () => {
expect(screen.queryByText('2 km')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-205: the legs of a day are fetched together, not one after the other', async () => {
const { calculateRouteWithLegs } = await import('../Map/RouteCalculator')
const release: (() => void)[] = []
vi.mocked(calculateRouteWithLegs as any).mockImplementation(() => new Promise(resolve => {
release.push(() => resolve({
coordinates: [], distance: 0, duration: 0,
legs: [{ distanceText: '2 km', durationText: '10 min', drivingText: '10 min', walkingText: '25 min' }],
}))
}))
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const places = [
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
buildPlace({ id: 3, name: 'C', lat: 48.87, lng: 2.37 }),
buildPlace({ id: 4, name: 'D', lat: 48.88, lng: 2.38 }),
]
const assignments = {
'10': places.map((place, i) => buildAssignment({ id: i + 1, day_id: 10, order_index: i, place })),
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places, assignments, selectedDayId: 10, routeShown: true })} />)
// All three legs are in flight while none of them has answered yet.
await waitFor(() => expect(release).toHaveLength(3))
release.forEach(fn => fn())
// And the connectors still land in one go once they come back.
await waitFor(() => expect(screen.getAllByText('2 km')).toHaveLength(3))
})
it('FE-PLANNER-DAYPLAN-123: adding a note to a collapsed day expands it first', async () => {
const user = userEvent.setup()
mockDayNotesState.openAddNote.mockImplementation((dayId: number, _getMerged: unknown, expand: (id: number) => void) => expand(dayId))
@@ -2664,6 +2710,86 @@ describe('DayPlanSidebar', () => {
await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('server said no'))
})
it('FE-PLANNER-DAYPLAN-206: a rejected slot write snaps the booking order back', async () => {
const { reservationsApi } = await import('../../api/client')
vi.mocked(reservationsApi.updatePositions).mockRejectedValueOnce(new Error('server said no'))
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const placeA = buildPlace({ id: 1, name: 'Place A' })
const placeB = buildPlace({ id: 2, name: 'Place B' })
const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA })
const a2 = buildAssignment({ id: 12, day_id: 10, order_index: 1, place: placeB })
const bus = buildReservation({ id: 430, type: 'bus', title: 'Airport bus', day_id: 10, day_plan_position: 1.5 })
seedStore(useTripStore, { reservations: [bus] })
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [placeA, placeB], assignments: { '10': [a1, a2] },
reservations: [bus], onReorder: vi.fn(async () => undefined),
})} />)
fireEvent.dragStart(dragRow(screen.getByText('Place B')), { dataTransfer: emptyDataTransfer })
fireEvent.drop(dragRow(screen.getByText('Place A')), { dataTransfer: { getData: vi.fn(() => '') } })
await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('server said no'))
// The optimistic slot goes with it — otherwise the day keeps an order the
// server never accepted.
const stored = useTripStore.getState().reservations.find(r => r.id === 430)!
expect(stored.day_plan_position).toBe(1.5)
expect(stored.day_positions).toBeUndefined()
})
it('FE-PLANNER-DAYPLAN-207: a rejected note write leaves the stored booking slot alone', async () => {
const updateDayNote = vi.fn().mockRejectedValue(new Error('note write failed'))
stubTripActions({ updateDayNote })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const placeA = buildPlace({ id: 1, name: 'Place A' })
const placeB = buildPlace({ id: 2, name: 'Place B' })
const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA })
const a2 = buildAssignment({ id: 12, day_id: 10, order_index: 1, place: placeB })
const note = buildDayNote({ id: 70, day_id: 10, text: 'Pack sunscreen', sort_order: 1.4 })
const bus = buildReservation({ id: 430, type: 'bus', title: 'Airport bus', day_id: 10, day_plan_position: 1.5 })
mockDayNotesState.dayNotes = { '10': [note] }
seedStore(useTripStore, { reservations: [bus] })
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [placeA, placeB], assignments: { '10': [a1, a2] },
reservations: [bus], onReorder: vi.fn(async () => undefined),
})} />)
fireEvent.dragStart(dragRow(screen.getByText('Place B')), { dataTransfer: emptyDataTransfer })
fireEvent.drop(dragRow(screen.getByText('Place A')), { dataTransfer: { getData: vi.fn(() => '') } })
await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('note write failed'))
// The slot write went through before the note failed, so the day must keep it.
const stored = useTripStore.getState().reservations.find(r => r.id === 430)!
expect(stored.day_positions).toEqual({ 10: expect.any(Number) })
expect(stored.day_plan_position).not.toBe(1.5)
})
it('FE-PLANNER-DAYPLAN-208: a booking that arrived mid-write survives the rollback', async () => {
const { reservationsApi } = await import('../../api/client')
const ferry = buildReservation({ id: 431, type: 'ferry', title: 'Island ferry', day_id: 10 })
vi.mocked(reservationsApi.updatePositions).mockImplementationOnce(async () => {
// Stands in for a collaborator's reservation:created event landing while the
// slot write is still out.
useTripStore.setState(state => ({ reservations: [...state.reservations, ferry] }))
throw new Error('server said no')
})
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const placeA = buildPlace({ id: 1, name: 'Place A' })
const placeB = buildPlace({ id: 2, name: 'Place B' })
const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: placeA })
const a2 = buildAssignment({ id: 12, day_id: 10, order_index: 1, place: placeB })
const bus = buildReservation({ id: 430, type: 'bus', title: 'Airport bus', day_id: 10, day_plan_position: 1.5 })
seedStore(useTripStore, { reservations: [bus] })
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [placeA, placeB], assignments: { '10': [a1, a2] },
reservations: [bus], onReorder: vi.fn(async () => undefined),
})} />)
fireEvent.dragStart(dragRow(screen.getByText('Place B')), { dataTransfer: emptyDataTransfer })
fireEvent.drop(dragRow(screen.getByText('Place A')), { dataTransfer: { getData: vi.fn(() => '') } })
await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('server said no'))
const stored = useTripStore.getState().reservations
expect(stored.find(r => r.id === 430)!.day_plan_position).toBe(1.5)
expect(stored.find(r => r.id === 431)).toBeDefined()
})
it('FE-PLANNER-DAYPLAN-126: reordering around a multi-leg flight writes a position per leg', async () => {
const updateReservation = vi.fn(async () => ({} as Reservation))
stubTripActions({ updateReservation })
@@ -435,6 +435,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}))
// Mark as initialized immediately to prevent re-entry
for (const p of positions) initedTransportIds.current.add(p.id)
const before = positions.map(p => ({
id: p.id,
day_plan_position: useTripStore.getState().reservations.find(r => r.id === p.id)?.day_plan_position ?? null,
}))
// Update store so subscribers see the new positions
useTripStore.setState(state => ({
reservations: state.reservations.map(r => {
@@ -443,8 +447,20 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
return { ...r, day_plan_position: p.day_plan_position }
})
}))
// Persist to server (fire and forget)
reservationsApi.updatePositions(tripId, positions).catch(() => {})
// Persist to server. No toast — this is a bootstrap write nobody asked for —
// but the store has to go back to what the server still holds, otherwise the
// day shows an order that only exists in this tab.
// The ids stay marked as initialised: the effect reruns on every reservations
// change, so clearing them here would retry the same failing write in a loop.
reservationsApi.updatePositions(tripId, positions).catch(() => {
useTripStore.setState(state => ({
reservations: state.reservations.map(r => {
const p = before.find(x => x.id === r.id)
if (!p) return r
return { ...r, day_plan_position: p.day_plan_position }
})
}))
})
}
const getMergedItems = (dayId: number): MergedItem[] =>
@@ -581,36 +597,67 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
} catch { return undefined }
}
// Collect every leg of every day first, then fetch them a few at a time.
// Each result is stored under its own key, so nothing here depends on the
// order they come back in — which is what lets them overlap at all.
const tasks: (() => Promise<void>)[] = []
for (const dayId of routeDayIds) {
const { runs, startHotel, endHotel, firstWay, lastWay, wantTop, wantBottom } = planDay(dayId)
const dfMode = dayDefaultMode(dayId)
const dayLegs: Record<number, RouteSegment> = {}
legsByDay[dayId] = dayLegs
for (const run of runs) {
// One routing call per LEG, each with its own resolved mode, so a day can
// mix walking/driving/plugin legs. RouteCalculator's cache is keyed per
// profile+coords, so per-leg calls stay cheap (a single-mode day reuses
// the same entries) and each leg is tagged with the mode it was drawn in.
for (let i = 0; i < run.length - 1; i++) {
const seg = await legBetween({ lat: run[i].lat, lng: run[i].lng }, { lat: run[i + 1].lat, lng: run[i + 1].lng }, dayId, resolveLegMode(run[i], run[i + 1], dfMode))
if (seg) dayLegs[run[i].id] = seg
else if (controller.signal.aborted) return
const from = run[i]
const to = run[i + 1]
const mode = resolveLegMode(from, to, dfMode)
tasks.push(async () => {
const seg = await legBetween({ lat: from.lat, lng: from.lng }, { lat: to.lat, lng: to.lng }, dayId, mode)
if (seg) dayLegs[from.id] = seg
})
}
}
if (Object.keys(dayLegs).length) legsByDay[dayId] = dayLegs
const hotel: { top?: { seg: RouteSegment; name: string; targetId?: number }; bottom?: { seg: RouteSegment; name: string; targetId?: number } } = {}
hotelByDay[dayId] = hotel
if (wantTop) {
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstWay!.lat, lng: firstWay!.lng }, dayId, resolveLegMode({ isPlace: false }, firstWay!, dfMode))
if (seg) hotel.top = { seg, name: hotelName(startHotel!), targetId: firstWay!.isPlace ? firstWay!.id : undefined }
const mode = resolveLegMode({ isPlace: false }, firstWay!, dfMode)
tasks.push(async () => {
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstWay!.lat, lng: firstWay!.lng }, dayId, mode)
if (seg) hotel.top = { seg, name: hotelName(startHotel!), targetId: firstWay!.isPlace ? firstWay!.id : undefined }
})
}
if (wantBottom) {
const seg = await legBetween({ lat: lastWay!.lat, lng: lastWay!.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number }, dayId, resolveLegMode(lastWay!, { isPlace: false }, dfMode))
if (seg) hotel.bottom = { seg, name: hotelName(endHotel!), targetId: lastWay!.isPlace ? lastWay!.id : undefined }
const mode = resolveLegMode(lastWay!, { isPlace: false }, dfMode)
tasks.push(async () => {
const seg = await legBetween({ lat: lastWay!.lat, lng: lastWay!.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number }, dayId, mode)
if (seg) hotel.bottom = { seg, name: hotelName(endHotel!), targetId: lastWay!.isPlace ? lastWay!.id : undefined }
})
}
if (controller.signal.aborted) return
if (hotel.top || hotel.bottom) hotelByDay[dayId] = hotel
}
if (!controller.signal.aborted) { setRouteLegs(legsByDay); setHotelLegs(hotelByDay) }
// Small pool on purpose: a week of days is dozens of legs and the routing
// host is often a shared OSRM.
let nextTask = 0
const runTasks = async () => {
while (nextTask < tasks.length && !controller.signal.aborted) await tasks[nextTask++]()
}
await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, runTasks))
if (controller.signal.aborted) return
// Days that ended up without a single leg were never in the map before.
for (const dayId of Object.keys(legsByDay).map(Number)) {
if (!Object.keys(legsByDay[dayId]).length) delete legsByDay[dayId]
}
for (const dayId of Object.keys(hotelByDay).map(Number)) {
if (!hotelByDay[dayId].top && !hotelByDay[dayId].bottom) delete hotelByDay[dayId]
}
setRouteLegs(legsByDay)
setHotelLegs(hotelByDay)
})()
// routeDayIds is memoized from the same inputs as routeDayKey below, so keying the
// effect on the string is equivalent while staying stable across unrelated renders.
@@ -642,6 +689,25 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
// Capture previous place order for undo
const prevAssignmentIds = getDayAssignments(dayId).map(a => a.id)
// …and, per booking, the fields this call is about to overwrite, so a failed write
// can put the visible order back instead of leaving a phantom one behind the error
// toast. Restoring the whole array instead would also drop what a collaborator's
// socket event applied while the requests were in flight.
const pendingRollback: (Partial<Reservation> & { id: number })[] = []
const dropRollback = (id: number) => {
const i = pendingRollback.findIndex(p => p.id === id)
if (i >= 0) pendingRollback.splice(i, 1)
}
const rollBackReservations = () => {
if (!pendingRollback.length) return
useTripStore.setState(state => ({
reservations: state.reservations.map(r => {
const p = pendingRollback.find(x => x.id === r.id)
return p ? { ...r, ...p } : r
})
}))
setTransportPosVersion(v => v + 1)
}
// Places get sequential integer positions (0, 1, 2, ...)
// Non-place items between place N-1 and place N get fractional positions
@@ -683,6 +749,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Update transport positions in store FIRST so the useEffect triggered by
// onReorder's optimistic assignment update reads the correct positions.
if (transportUpdates.length) {
for (const tu of transportUpdates) {
const r = useTripStore.getState().reservations.find(x => x.id === tu.id)
if (r) pendingRollback.push({ id: r.id, day_plan_position: r.day_plan_position, day_positions: r.day_positions })
}
useTripStore.setState(state => ({
reservations: state.reservations.map(r => {
const tu = transportUpdates.find(u => u.id === r.id)
@@ -712,8 +782,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// here double-encodes it on the server, which wipes metadata.legs on read
// and collapses the flight back to a single span.
const newMeta = { ...parsed, legs }
pendingRollback.push({ id: rid, metadata: r.metadata })
useTripStore.setState(state => ({ reservations: state.reservations.map(x => (x.id === rid ? { ...x, metadata: newMeta } : x)) }))
await tripActions.updateReservation(tripId, rid, { metadata: newMeta })
// Stored, so a later step failing must not put the old legs back.
dropRollback(rid)
}
setTransportPosVersion(v => v + 1)
}
@@ -721,6 +794,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (transportUpdates.length) {
onRouteRefresh?.()
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
for (const tu of transportUpdates) dropRollback(tu.id)
}
for (const n of noteUpdates) {
await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
@@ -732,7 +806,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
} catch (err: unknown) {
rollBackReservations()
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
}
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false, toLegIndex = null) => {
@@ -67,6 +67,7 @@ const routableReservation = () => buildReservation({
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
localStorage.clear()
})
describe('DayPlanSidebarToolbar', () => {
@@ -142,7 +143,10 @@ describe('DayPlanSidebarToolbar', () => {
expect(btn).toHaveAttribute('aria-pressed', 'false')
await user.click(btn)
expect(setExpandedDays).toHaveBeenCalledWith(new Set([10, 11]))
expect(JSON.parse(sessionStorage.getItem('day-expanded-1')!)).toEqual([10, 11])
// The sidebar restores the set from localStorage on mount, so that is the
// store the toolbar has to write to.
expect(JSON.parse(localStorage.getItem('day-expanded-1')!)).toEqual([10, 11])
expect(sessionStorage.getItem('day-expanded-1')).toBeNull()
})
it('FE-PLANNER-DPTOOLBAR-012: with every day expanded the button collapses them all', async () => {
@@ -154,7 +158,7 @@ describe('DayPlanSidebarToolbar', () => {
expect(btn).toHaveAttribute('aria-pressed', 'true')
await user.click(btn)
expect(setExpandedDays).toHaveBeenCalledWith(new Set())
expect(JSON.parse(sessionStorage.getItem('day-expanded-1')!)).toEqual([])
expect(JSON.parse(localStorage.getItem('day-expanded-1')!)).toEqual([])
})
it('FE-PLANNER-DPTOOLBAR-013: a trip with no days is never treated as fully expanded', () => {
@@ -98,7 +98,9 @@ export function DayPlanSidebarToolbar({
onClick={() => {
const next = allExpanded ? new Set<number>() : new Set(days.map(d => d.id))
setExpandedDays(next)
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...next])) } catch {}
// Same store the sidebar reads on mount — a sessionStorage write
// here left the persisted set behind after a reload.
try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...next])) } catch {}
}}
aria-label={label}
aria-pressed={allExpanded}
@@ -208,6 +208,20 @@ describe('PlaceDetailsColumn', () => {
)
})
it('FE-PDC-038: a fact url that is not http(s) stays a chip, never a link', async () => {
// fact.url comes straight from an OSM tag anyone can edit, so a
// javascript: value must not end up in an href.
placeEnrichment.mockResolvedValue({
photos: [],
description: null,
facts: [{ kind: 'menu', value: 'Menu', url: 'javascript:alert(1)' }],
})
renderColumn()
expect(await screen.findByText('Menu')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /Menu/ })).not.toBeInTheDocument()
})
it('FE-PDC-017: renders facts even when there is neither a picture nor a description', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
@@ -26,6 +26,7 @@ import type { MapsPlaceEnrichmentResult, PlaceFact, PlaceHours, PlacePhotoCandid
import { mapsApi } from '../../api/client'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
import { safeHttpUrl } from '../../utils/safeUrl'
import type { TranslationFn } from '../../types'
/** The place the column is describing. Null while nothing is selected. */
@@ -616,11 +617,14 @@ function FactList({ facts, t }: { facts: PlaceFact[]; t: TranslationFn }): React
</>
)
const shared = 'inline-flex items-center gap-1.5 max-w-full rounded-full border border-edge bg-surface px-2 py-1 text-caption text-content-secondary'
// A fact url is a community-edited OSM tag (website:menu and friends),
// so anything but http(s) renders as the plain chip instead of a link.
const href = safeHttpUrl(fact.url)
return fact.url ? (
return href ? (
<a
key={fact.kind}
href={fact.url}
href={href}
target="_blank"
rel="noopener noreferrer"
title={label}
@@ -1423,4 +1423,27 @@ describe('ReservationModal', () => {
// Hotel place picker still shows its placeholder — no crash on the missing record.
expect(screen.getByText('—')).toBeInTheDocument();
});
// A legacy or corrupted metadata column used to throw straight out of the open
// effect and take the whole planner route down.
it('FE-PLANNER-RESMODAL-087: unparseable metadata opens the modal with empty check-in times', () => {
const res = buildReservation({ id: 17, title: 'Grand Hotel', type: 'hotel' });
(res as unknown as { metadata: string }).metadata = '{not json';
render(<ReservationModal {...defaultProps} reservation={res} days={reviewDays()} />);
expect(screen.getByDisplayValue('Grand Hotel')).toBeInTheDocument();
const times = screen.getAllByTestId('time-picker') as HTMLInputElement[];
expect(times.map(i => i.value)).toEqual(['', '', '']);
});
it('FE-PLANNER-RESMODAL-088: double-encoded metadata still fills the check-in times', () => {
const res = buildReservation({ id: 18, title: 'Grand Hotel', type: 'hotel' });
(res as unknown as { metadata: string }).metadata =
JSON.stringify(JSON.stringify({ check_in_time: '16:00', check_out_time: '10:30' }));
render(<ReservationModal {...defaultProps} reservation={res} days={reviewDays()} />);
const times = screen.getAllByTestId('time-picker') as HTMLInputElement[];
expect(times[0].value).toBe('16:00');
expect(times[2].value).toBe('10:30');
});
});
@@ -12,6 +12,7 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import { openFile } from '../../utils/fileDownload'
import { parseReservationMetadata } from '../../utils/flightLegs'
import { resolveDayId } from '../../utils/formatters'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
@@ -133,7 +134,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
setTravelerIds(new Set((reservation?.travelers || []).map(tv => tv.user_id)))
if (reservation) {
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
const meta = parseReservationMetadata(reservation)
const rawEnd = reservation.reservation_end_time || ''
let endDate = ''
let endTime = rawEnd
@@ -1,4 +1,4 @@
// FE-COMP-RES-001 to FE-COMP-RES-040, FE-PLANNER-RESP-016 to FE-PLANNER-RESP-078
// FE-COMP-RES-001 to FE-COMP-RES-040, FE-PLANNER-RESP-016 to FE-PLANNER-RESP-080
import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -900,4 +900,21 @@ describe('ReservationsPanel', () => {
const text = document.body.textContent || '';
expect(text.indexOf('Dated train')).toBeLessThan(text.indexOf('Undated taxi'));
});
it('FE-PLANNER-RESP-079: an http booking link is rendered as an anchor', () => {
const res = buildReservation({ id: 1, title: 'Hotel', status: 'confirmed', url: 'https://hotel.example/booking' } as any);
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const link = screen.getByText('https://hotel.example/booking');
expect(link.tagName).toBe('A');
expect(link).toHaveAttribute('href', 'https://hotel.example/booking');
});
it('FE-PLANNER-RESP-080: a javascript: booking link stays plain text, never an href', () => {
const res = buildReservation({ id: 1, title: 'Hotel', status: 'confirmed', url: 'javascript:alert(1)' } as any);
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
// The stored value stays readable, but nothing is clickable.
const shown = screen.getByText('javascript:alert(1)');
expect(shown.tagName).toBe('SPAN');
expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
});
});
@@ -12,6 +12,7 @@ import {
TramFront, Footprints, StickyNote, ParkingSquare,
} from 'lucide-react'
import { openFile } from '../../utils/fileDownload'
import { safeExternalHref } from '../../utils/safeUrl'
import { TransitTitle, TransitLegChips, TransitMetaBadges, fmtTransitDuration } from './transitDisplay'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -116,6 +117,10 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
})()
const attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id))
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
// A booking link is deliberately free-form - people paste bare hosts and long
// provider deep links - so this refuses the schemes that execute in this
// origin and leaves everything else linkable.
const bookingUrl = safeExternalHref(r.url)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const handleDelete = async () => {
@@ -432,8 +437,12 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div className={fieldLabelClass}>{t('reservations.urlLabel')}</div>
<div className={fieldValueClass} style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400 }}>
<ExternalLink size={13} className="text-content-faint" style={{ flexShrink: 0 }} />
<a href={r.url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline"
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.url}</a>
{bookingUrl ? (
<a href={bookingUrl} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline"
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{bookingUrl}</a>
) : (
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.url}</span>
)}
</div>
</div>
)}
@@ -1,4 +1,4 @@
// FE-PLUGINS-FRAME-001 to 046
// FE-PLUGINS-FRAME-001 to 061
import { render, cleanup, waitFor, fireEvent, screen, act } from '@testing-library/react';
import PluginFrame from './PluginFrame';
import { usePluginStore } from '../../store/pluginStore';
@@ -340,12 +340,12 @@ describe('PluginFrame', () => {
function mount(granted: boolean) {
grant(granted);
const { container, unmount } = render(<PluginFrame pluginId="demo" />);
const iframe = container.querySelector('iframe')!;
const view = render(<PluginFrame pluginId="demo" />);
const iframe = view.container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) =>
posted.push(m as Record<string, unknown>);
return { iframe, posted, unmount };
return { ...view, iframe, posted };
}
it('FE-PLUGINS-FRAME-015: refuses an ungranted plugin without touching the browser API', () => {
@@ -381,6 +381,33 @@ describe('PluginFrame', () => {
expect(geo.clearWatch).toHaveBeenCalledWith(7);
});
it('FE-PLUGINS-FRAME-060: a host settings change re-bridges without dropping the watch', () => {
let tick: ((p: unknown) => void) | null = null;
geo.watchPosition.mockImplementation((ok: (p: unknown) => void) => { tick = ok; return 7; });
const { iframe, posted, rerender } = mount(true);
act(() => { fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g20', action: 'watch' }); });
// Any settings write re-runs the bridge effect; the watch must survive it.
host.settings = { ...DEFAULT_SETTINGS, distance_unit: 'imperial' };
act(() => { rerender(<PluginFrame pluginId="demo" />); });
expect(geo.clearWatch).not.toHaveBeenCalled();
const before = posted.length;
act(() => tick!({ coords: { latitude: 5, longitude: 6, accuracy: 5, heading: null, speed: null }, timestamp: 3 }));
expect(posted.length).toBe(before + 1);
});
it('FE-PLUGINS-FRAME-061: a frame that navigates itself has its watch released', () => {
const { iframe } = mount(true);
act(() => { fireEvent.load(iframe); });
act(() => { fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g21', action: 'watch' }); });
// Second load = the frame navigated away, so nothing receives the stream any more.
act(() => { fireEvent.load(iframe); });
expect(geo.clearWatch).toHaveBeenCalledWith(7);
});
it('FE-PLUGINS-FRAME-018: clear stops the watch on request', () => {
const { iframe, posted } = mount(true);
fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g4', action: 'watch' });
+13 -3
View File
@@ -650,8 +650,6 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
window.removeEventListener('message', onMessage)
themeObserver.disconnect()
if (wsForward) removeListener(wsForward)
// The frame is going away (or re-bridging) — never leave a live GPS watch behind.
if (geoWatchRef.current != null) { navigator.geolocation.clearWatch(geoWatchRef.current); geoWatchRef.current = null }
}
}, [pluginId, tripId, fill, navigate, postFrame, buildContext, userId])
@@ -664,6 +662,11 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
setHeight(null)
confirmReqRef.current = null
setConfirmReq(null)
// Only a real teardown may kill the watch: the bridge above re-runs on any
// settings, locale or breakpoint change, and nothing would re-arm it.
return () => {
if (geoWatchRef.current != null) { navigator.geolocation.clearWatch(geoWatchRef.current); geoWatchRef.current = null }
}
}, [pluginId])
const answerConfirm = (confirmed: boolean) => {
@@ -689,7 +692,14 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
// Deliver the context as soon as the document is parsed (the plugin sets up its
// message listener during parse), closing the trek:ready race so the theme is
// right on first paint. A 2nd load is a self-navigation — don't bridge to it.
onLoad={() => { setLoadFailed(false); loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }}
onLoad={() => {
setLoadFailed(false)
loadsRef.current += 1
if (loadsRef.current === 1) postFrame(buildContext())
// A self-navigated frame is no longer bridged, so its watch is only
// burning battery for positions nobody receives.
else if (geoWatchRef.current != null) { navigator.geolocation.clearWatch(geoWatchRef.current); geoWatchRef.current = null }
}}
onError={() => setLoadFailed(true)}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
@@ -298,4 +298,35 @@ describe('AppearanceSettingsTab – dashboard widgets', () => {
PERSIST,
);
});
// The scheme is already on the DOM by then, so dropping the write would revert the
// choice on the next load without ever telling the user.
it('FE-COMP-APPEARANCE-023: leaving the tab before the debounce fires flushes the pending save once', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance();
const { unmount } = render(<AppearanceSettingsTab />);
await user.click(screen.getByText('Indigo'));
unmount();
const calls = updateSetting.mock.calls.filter(c => c[0] === 'appearance');
expect(calls).toHaveLength(1);
expect(calls[0][1]).toMatchObject({ schemeId: 'indigo' });
// The timer is cancelled, so nothing lands a second time.
await new Promise(resolve => setTimeout(resolve, 500));
expect(updateSetting.mock.calls.filter(c => c[0] === 'appearance')).toHaveLength(1);
});
it('FE-COMP-APPEARANCE-024: a debounce that already fired is not written again on unmount', async () => {
const user = userEvent.setup();
const updateSetting = seedAppearance();
const { unmount } = render(<AppearanceSettingsTab />);
await user.click(screen.getByText('Teal'));
await waitFor(() => expect(updateSetting.mock.calls.filter(c => c[0] === 'appearance')).toHaveLength(1), PERSIST);
unmount();
expect(updateSetting.mock.calls.filter(c => c[0] === 'appearance')).toHaveLength(1);
});
});
@@ -81,6 +81,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
const [cfg, setCfg] = useState<AppearanceConfig>(() => normalizeAppearance(settings.appearance))
const persistTimer = useRef<number | undefined>(undefined)
// What the pending timer would have written, so leaving the tab inside the
// debounce window still saves instead of silently dropping the change.
const pendingWrite = useRef<AppearanceConfig | null>(null)
// Re-sync when settings change elsewhere (e.g. server reconcile / another tab).
useEffect(() => {
@@ -89,8 +92,11 @@ export default function AppearanceSettingsTab(): React.ReactElement {
// Flush any pending persist on unmount.
useEffect(() => () => {
if (persistTimer.current) window.clearTimeout(persistTimer.current)
}, [])
if (!persistTimer.current) return
window.clearTimeout(persistTimer.current)
// The component is gone, so a failure has nowhere to be shown.
if (pendingWrite.current) updateSetting('appearance', pendingWrite.current).catch(() => {})
}, [updateSetting])
const isDark =
settings.dark_mode === true ||
@@ -103,7 +109,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
setCfg(next)
applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false })
if (persistTimer.current) window.clearTimeout(persistTimer.current)
pendingWrite.current = next
persistTimer.current = window.setTimeout(() => {
pendingWrite.current = null
updateSetting('appearance', next).catch((e: unknown) =>
toast.error(e instanceof Error ? e.message : t('common.error'))
)
@@ -755,6 +755,59 @@ describe('NotificationsTab', () => {
expect(screen.getByText('Connection refused')).toBeInTheDocument();
});
});
it('FE-COMP-NOTIFICATIONS-030: a failing toggle surfaces the generic error', async () => {
const user = userEvent.setup();
server.use(
http.put('/api/notifications/preferences', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
);
render(<><NotificationsTab /><ToastContainer /></>);
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
const toggles = await screen.findAllByRole('button');
await user.click(toggles[0]);
expect(await screen.findByText('Error')).toBeInTheDocument();
});
it('FE-COMP-NOTIFICATIONS-031: a failing toggle leaves a later toggle alone', async () => {
const user = userEvent.setup();
const bodies: Record<string, Record<string, boolean>>[] = [];
let rejectEmail!: () => void;
server.use(
http.put('/api/notifications/preferences', async ({ request }) => {
const body = (await request.json()) as Record<string, Record<string, boolean>>;
bodies.push(body);
if (body.trip_invite?.email !== undefined) {
return new Promise<Response>(resolve => {
rejectEmail = () => resolve(HttpResponse.json({ error: 'nope' }, { status: 500 }) as unknown as Response);
});
}
return HttpResponse.json({ success: true });
}),
);
render(<><NotificationsTab /><ToastContainer /></>);
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
// Email is off in this matrix, in-app is on.
const [email, inapp] = await screen.findAllByRole('button');
await user.click(email);
await waitFor(() => expect(screen.getAllByRole('button')[0]).toHaveAttribute('aria-pressed', 'true'));
// Flipped while the email write is still hanging.
await user.click(inapp);
await waitFor(() => expect(bodies).toHaveLength(2));
await waitFor(() => expect(screen.getAllByRole('button')[1]).toHaveAttribute('aria-pressed', 'false'));
// The in-app write must not carry the email cell, or the server would keep the
// value the failing write is about to take back.
expect(bodies[1].trip_invite.email).toBeUndefined();
rejectEmail();
await waitFor(() => expect(screen.getAllByRole('button')[0]).toHaveAttribute('aria-pressed', 'false'));
// The in-app toggle the user made meanwhile is not undone.
expect(screen.getAllByRole('button')[1]).toHaveAttribute('aria-pressed', 'false');
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -116,16 +116,24 @@ export default function NotificationsTab(): React.ReactElement {
const toggle = async (eventType: string, channel: string) => {
if (!matrix) return
const current = matrix.preferences[eventType]?.[channel] ?? true
const updated = {
...matrix.preferences,
[eventType]: { ...matrix.preferences[eventType], [channel]: !current },
}
setMatrix(m => m ? { ...m, preferences: updated } : m)
setMatrix(m => m ? {
...m,
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: !current } },
} : m)
setSaving(true)
try {
await notificationsApi.updatePreferences(updated)
// Only the toggled cell goes out. The server merges what it gets, and sending the
// whole matrix would carry this render's value for every other cell — a second
// toggle made while this request is in flight would be overwritten by it.
await notificationsApi.updatePreferences({ [eventType]: { [channel]: !current } })
} catch {
setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m)
// Only this cell rolls back — restoring the whole snapshot would also undo a
// toggle the user made while this request was in flight.
setMatrix(m => m ? {
...m,
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: current } },
} : m)
toast.error(t('common.error'))
} finally {
setSaving(false)
}
+15 -1
View File
@@ -1,4 +1,4 @@
import { AlertTriangle, Check, CloudOff, RefreshCw } from 'lucide-react'
import { AlertTriangle, Check, CloudOff, Eye, RefreshCw } from 'lucide-react'
import type { BookRecord } from '@trek/shared'
import type { SaveState } from './useBookStore'
@@ -43,6 +43,20 @@ export function SaveIndicator({
)
}
if (state.status === 'readonly') {
/*
* Not an error and not retryable: this person was invited to read the
* journey, not to write it. Said once, plainly, and left standing — the
* alternative is an editor that looks like it is saving and is not.
*/
return (
<div className="st-save is-error" role="status">
<Eye size={13} />
<span>{t('journey.studio.saveReadOnly')}</span>
</div>
)
}
if (state.status === 'error') {
return (
<button className="st-save is-error" onClick={onRetry} title={t('journey.studio.saveRetry')}>
+28 -10
View File
@@ -7,9 +7,21 @@ import type { PeerCursor } from './useBookPresence'
import { FONT_STACKS } from './bookRender'
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
import { useStudioStore } from '../../store/studioStore'
import { useTranslation } from '../../i18n'
const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
/**
* What a delete may actually take out of the selection.
*
* A locked element stays selectable on purpose, so the lock has to be honoured
* here the way the drag, resize and rotate gestures honour it. The store keeps
* removing whatever it is handed: the import and repair paths remove locked
* elements deliberately.
*/
const deletable = (spread: BookSpread | undefined, selection: string[]): string[] =>
(spread?.elements ?? []).filter(e => selection.includes(e.id) && !e.locked).map(e => e.id)
/**
* Where the four rotation handles sit, as percentages of the selection box.
*
@@ -66,6 +78,7 @@ export function StudioCanvas({
/** Where this one is, in the spread's millimetres. Null once it leaves. */
onCursor?: (x: number | null, y: number | null) => void
}) {
const { t } = useTranslation()
const selection = useStudioStore(s => s.selection)
const select = useStudioStore(s => s.select)
const removeElements = useStudioStore(s => s.removeElements)
@@ -123,12 +136,13 @@ export function StudioCanvas({
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if ((e.key === 'Delete' || e.key === 'Backspace') && selection.length) {
e.preventDefault()
removeElements(spreadIndex, selection)
const ids = deletable(spread, selection)
if (ids.length) removeElements(spreadIndex, ids)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [selection, spreadIndex, removeElements])
}, [selection, spread, spreadIndex, removeElements])
if (!spread) return null
@@ -346,7 +360,7 @@ export function StudioCanvas({
className={`st-rotate is-${c.id}`}
style={{ left: c.left, top: c.top }}
onPointerDown={startRotate}
title="Rotate"
title={t('journey.studio.rotate')}
>
<RotateCw size={11} />
</div>
@@ -380,15 +394,15 @@ export function StudioCanvas({
style={{ left: (x0 + x1) / 2, top: Math.max(6, y0 - 12) }}
onPointerDown={e => e.stopPropagation()}
>
<button onClick={() => duplicate(spreadIndex, selection)} title="Duplicate">
<button onClick={() => duplicate(spreadIndex, selection)} title={t('journey.studio.duplicate')}>
<Copy size={14} />
</button>
{sel.length === 1 && (
<>
<button onClick={() => raise(spreadIndex, sel[0].id, 'front')} title="Bring to front">
<button onClick={() => raise(spreadIndex, sel[0].id, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button onClick={() => raise(spreadIndex, sel[0].id, 'back')} title="Send to back">
<button onClick={() => raise(spreadIndex, sel[0].id, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</>
@@ -405,13 +419,13 @@ export function StudioCanvas({
*/}
<button
onClick={e => rotateBy(e.shiftKey ? -1 : -15)}
title="Rotate left"
title={t('journey.studio.rotateLeft')}
>
<RotateCcw size={14} />
</button>
<button
onClick={e => rotateBy(e.shiftKey ? 1 : 15)}
title="Rotate right"
title={t('journey.studio.rotateRight')}
>
<RotateCw size={14} />
</button>
@@ -424,12 +438,16 @@ export function StudioCanvas({
elements: sp.elements.map(e => (selection.includes(e.id) ? { ...e, locked: !locked } : e)),
})),
}))}
title={locked ? 'Unlock' : 'Lock'}
title={t(locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
>
{locked ? <Unlock size={14} /> : <Lock size={14} />}
</button>
<span className="st-quickbar-sep" />
<button className="is-danger" onClick={() => removeElements(spreadIndex, selection)} title="Delete">
<button
className="is-danger"
onClick={() => { const ids = deletable(spread, selection); if (ids.length) removeElements(spreadIndex, ids) }}
title={t('journey.studio.delete')}
>
<Trash2 size={14} />
</button>
</div>
@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react'
import type { BookElement, BookMetric, BookPageSetup, JourneyStats } from '@trek/shared'
import { BOOK_METRICS } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
@@ -95,6 +96,19 @@ export function StudioTravelPanel({
const spread = doc?.spreads[active]
const single = !!spread && spread.role !== 'inner'
/*
* The road lookups still running when the panel goes away.
*
* fetchRoads walks the legs at a deliberate pace, so a twenty-stop map keeps
* asking a public router for minutes after Studio is closed, and the answer
* lands in whatever document the store holds by then.
*/
const roadJobs = useRef(new Set<AbortController>())
useEffect(() => () => {
roadJobs.current.forEach(c => c.abort())
roadJobs.current.clear()
}, [])
const centre = (w: number, h: number) => {
const W = single ? page.pageWidth : page.pageWidth * 2
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
@@ -304,13 +318,17 @@ export function StudioTravelPanel({
* free, and a failure leaves the leg exactly as it was.
*/
if (placed.kind === 'map' && placed.points.length > 1) {
void fetchRoads(placed.points.map(pt => ({ lat: pt.lat, lng: pt.lng })))
const job = new AbortController()
roadJobs.current.add(job)
void fetchRoads(placed.points.map(pt => ({ lat: pt.lat, lng: pt.lng })), { signal: job.signal })
.then(roads => {
if (job.signal.aborted) return
if (roads.some(r => r && r.length > 1)) {
updateElement(active, placed.id, { roads } as Partial<BookElement>)
}
})
.catch(() => { /* No roads is a state the map already draws. */ })
.finally(() => { roadJobs.current.delete(job) })
}
}
@@ -221,7 +221,7 @@ function fillBadge(el: BookElement & { kind: 'badge' }, entry: TemplateEntry, ct
}
}
if (el.variant === 'distance' && ctx.stats?.distance) {
el.text = `${Math.round(ctx.stats.distance / 1000).toLocaleString('en-US')} km`
el.text = `${Math.round(ctx.stats.distance / 1000).toLocaleString(ctx.locale)} km`
}
}
+5 -5
View File
@@ -1369,7 +1369,7 @@ function summarySpread(input: AutoInput): BookSpread | null {
els.push(badgeEl(
{ x: rx + 8 + lineW * 0.5 - 17, y: y - 11, w: 34, h: 8 },
'distance',
{ text: formatKm(stats.furthest) },
{ text: formatKm(stats.furthest, input.locale) },
{ style: 'chip', textScale: 0.7 },
))
els.push(text({ x: rx + 4, y: y + 4, w: lineW * 0.5, h: 5 }, (home.label || '').toUpperCase(), {
@@ -1396,9 +1396,9 @@ function furthestPoint(stats: JourneyStats) {
return best
}
/** Metres as the books print them: "1,189 km". */
function formatKm(metres: number): string {
return `${Math.round(metres / 1000).toLocaleString('en-US')} km`
/** Metres as the books print them: "1,189 km", in the book's own language. */
function formatKm(metres: number, locale: string): string {
return `${Math.round(metres / 1000).toLocaleString(locale)} km`
}
function countriesSpread(input: AutoInput, names: string[]): BookSpread | null {
@@ -1477,7 +1477,7 @@ function backSpread(input: AutoInput): BookSpread {
if (input.stats) {
const closing: BookMetric[] = ([
['days', input.stats.days],
['places', input.stats.places || input.stats.steps],
['places', input.stats.places],
['photos', input.stats.photos],
] as [BookMetric, number][])
.filter(([, v]) => v > 0)
+32 -2
View File
@@ -55,6 +55,8 @@ export type SaveState =
| { status: 'saving' }
| { status: 'saved'; at: number }
| { status: 'conflict'; current: BookRecord }
/** The journey is open to this user, but they may not write it (role 'viewer'). */
| { status: 'readonly' }
| { status: 'error' }
export function useBookStore(
@@ -73,6 +75,9 @@ export function useBookStore(
const pending = useRef<{ document: BookDocument; title: string } | null>(null)
const inFlight = useRef(false)
/** The title the last queued save carried, so a re-queue does not lose it. */
const lastTitle = useRef('')
/**
* The document the server and this client agree on, by identity.
*
@@ -173,6 +178,15 @@ export function useBookStore(
if (res?.status === 409 && res.data?.current) {
blocked.current = true
setState({ status: 'conflict', current: res.data.current })
} else if (res?.status === 403) {
/*
* A viewer can open Studio - the book is part of the journey they were
* invited to - but the server will not take their writes. Stop trying
* on the first refusal and say so: retrying forever would let someone
* lay out an entire book before finding out none of it was ever saved.
*/
blocked.current = true
setState({ status: 'readonly' })
} else {
setState({ status: 'error' })
}
@@ -193,6 +207,7 @@ export function useBookStore(
*/
const queueSave = useCallback((document: BookDocument, title: string) => {
latest.current = document
lastTitle.current = title
// The document we already agree on is not an edit — see `synced`.
if (document === synced.current) return
pending.current = { document, title }
@@ -207,7 +222,10 @@ export function useBookStore(
/** Write immediately — for closing the editor, or a save the user asked for. */
const saveNow = useCallback((document?: BookDocument, title?: string) => {
if (document) pending.current = { document, title: title ?? '' }
if (document) {
if (title != null) lastTitle.current = title
pending.current = { document, title: title ?? lastTitle.current }
}
if (timer.current != null) window.clearTimeout(timer.current)
return write()
}, [write])
@@ -236,8 +254,20 @@ export function useBookStore(
const keepMine = useCallback((current: BookRecord) => {
version.current = current.version
blocked.current = false
/*
* The refused document has to be queued again here, because write() cleared
* the queue before it made the request that came back 409. Without this,
* choosing "keep mine" left the work sitting in the tab: the autosave only
* fires on the next change, and closing the editor writes whatever is
* queued, which is nothing.
*/
if (latest.current && latest.current !== synced.current) {
pending.current = { document: latest.current, title: lastTitle.current }
void write()
return
}
setState({ status: 'idle' })
}, [])
}, [write])
/**
* Someone else saved.
@@ -298,6 +298,18 @@ describe('ModalRenderer', () => {
expect(screen.getByText('Hello Alice, welcome to TREK')).toBeTruthy();
});
it('FE-SN-MODAL-057: a body param is inserted literally, not as a replacement pattern', async () => {
const notice = makeNotice({
bodyKey: 'Version {version} is here',
bodyParams: { version: '4.0.0 ($&)' },
});
render(<ModalRenderer notices={[notice]} />);
await flushGraceDelay();
expect(screen.getByText('Version 4.0.0 ($&) is here')).toBeTruthy();
});
it('FE-SN-MODAL-008: empty notices renders nothing', () => {
const { container } = render(<ModalRenderer notices={[]} />);
expect(container.firstChild).toBeNull();
@@ -617,13 +617,17 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
type NoticeState = ReturnType<typeof useSystemNoticeModal>;
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Build the NoticeContent props for a given notice + pager slot index.
function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps {
const { t, isDark, canPage, notices, handleDismissAll, handleCTA, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S;
const rawBody = t(n.bodyKey);
// The key is escaped into the pattern and the value goes in through a function, so a
// value carrying $&, $1 or $` is inserted as typed instead of being expanded.
const body = n.bodyParams
? Object.entries(n.bodyParams).reduce(
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
(s, [k, v]) => s.replace(new RegExp(`\\{${escapeRe(k)}\\}`, 'g'), () => v),
rawBody
)
: rawBody;
@@ -1,4 +1,4 @@
// FE-COMP-TODO-001 to FE-COMP-TODO-052
// FE-COMP-TODO-001 to FE-COMP-TODO-079
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -24,6 +24,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(window, 'innerWidth', { value: 0, writable: true, configurable: true });
});
@@ -202,6 +203,25 @@ describe('TodoListPanel', () => {
expect(screen.queryByText('Future Task')).not.toBeInTheDocument();
});
it('FE-COMP-TODO-079: Overdue uses the local calendar day, not the UTC one', () => {
// Freeze on an instant whose UTC date differs from the local one, so a UTC-derived
// "today" would move the cut-off a day in whichever direction this runner sits.
const offset = new Date(2026, 4, 15, 12).getTimezoneOffset();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 4, 15, offset > 0 ? 23 : 0, 30));
const items = [
buildTodoItem({ id: 1, name: 'Due Today', checked: 0, due_date: '2026-05-15' }),
buildTodoItem({ id: 2, name: 'Due Yesterday', checked: 0, due_date: '2026-05-14' }),
];
render(<TodoListPanel tripId={1} items={items} />);
const overdueBtn = screen.getAllByRole('button').find(
b => b.textContent?.includes('Overdue') || b.getAttribute('title') === 'Overdue'
);
fireEvent.click(overdueBtn!);
expect(screen.getByText('Due Yesterday')).toBeInTheDocument();
expect(screen.queryByText('Due Today')).not.toBeInTheDocument();
});
it('FE-COMP-TODO-017: My Tasks filter shows only items assigned to current user', async () => {
// Use default current_user_id: 1 from beforeEach; assign one item to user 1
const items = [
+4 -1
View File
@@ -6,6 +6,7 @@ import { useTranslation } from '../../i18n'
import apiClient from '../../api/client'
import { formatDate as fmtDate } from '../../utils/formatters'
import type { TodoItem } from '../../types'
import { localToday } from '../Planner/today'
import type { FilterType, Member } from './todoListModel'
/**
@@ -65,7 +66,9 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
return Array.from(cats).sort()
}, [items])
const today = new Date().toISOString().split('T')[0]
// due_date is a bare calendar date, so "today" has to be the user's calendar
// day: toISOString() hands over the UTC one and shifts the overdue cut-off.
const today = localToday()
const filtered = useMemo(() => {
let result: TodoItem[]
@@ -1,4 +1,4 @@
// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-082
// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-084
import type { Mock } from 'vitest';
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -1214,4 +1214,37 @@ describe('TripFormModal', () => {
await waitFor(() => expect(staleResolved).toBe(true));
expect(screen.queryByText('Stale failure')).not.toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-083: a closed modal fetches nothing until it is opened', async () => {
// The trip planner keeps the modal mounted behind the page.
const seen: string[] = [];
server.use(
http.get('/api/auth/users', () => { seen.push('users'); return HttpResponse.json({ users: [] }); }),
http.get('/api/trips/:id/members', () => { seen.push('members'); return HttpResponse.json({ members: [] }); }),
);
const trip = buildTrip({ id: 5 });
const { rerender } = render(<TripFormModal {...defaultProps} isOpen={false} trip={trip} />);
await new Promise(resolve => setTimeout(resolve, 0));
expect(seen).toEqual([]);
rerender(<TripFormModal {...defaultProps} isOpen trip={trip} />);
await waitFor(() => expect(seen).toContain('users'));
expect(seen).toContain('members');
});
it('FE-COMP-TRIPFORM-084: staging another cover revokes the preview it replaces', async () => {
const revokeObjectURL = vi.fn();
const original = URL.revokeObjectURL;
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: revokeObjectURL });
createObjectURL.mockReturnValueOnce('blob:first').mockReturnValueOnce('blob:second');
render(<TripFormModal {...defaultProps} trip={null} />);
fireEvent.change(fileInput(), { target: { files: [pngFile()] } });
await waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(1));
fireEvent.change(fileInput(), { target: { files: [pngFile()] } });
await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:first'));
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: original });
});
});
+22 -7
View File
@@ -40,6 +40,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const isEditing = !!trip
const fileRef = useRef<HTMLInputElement>(null)
const coverSearchSeq = useRef(0)
// The staged cover lives on as an object URL until it is replaced or the modal goes.
const previewUrlRef = useRef<string | null>(null)
const toast = useToast()
const { t } = useTranslation()
const currentUser = useAuthStore(s => s.user)
@@ -104,6 +106,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
setCoverPreview(null)
setCoverSearchQuery('')
}
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current)
previewUrlRef.current = null
}
setPendingCoverFile(null)
setPendingUnsplashUrl(null)
setCoverSearchResults([])
@@ -112,16 +118,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
setPendingDateShift(null)
setDateShiftMode('keep_bookings')
setError('')
setExistingMembers([])
// The planner keeps this modal mounted while it is closed, so nothing may be
// fetched until it is actually open.
if (isOpen) {
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {})
}
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
if (trip) {
tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {})
} else {
setExistingMembers([])
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
if (trip) {
tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {})
}
}
}, [trip, isOpen])
@@ -131,6 +138,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}
}, [tripRemindersEnabled])
// A staged cover that never got uploaded would otherwise pin the full image for
// as long as the tab lives.
useEffect(() => () => {
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
}, [])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
@@ -219,7 +232,9 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
} else {
// New trip: stage for upload after creation
setPendingCoverFile(normalized)
setCoverPreview(URL.createObjectURL(normalized))
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
previewUrlRef.current = URL.createObjectURL(normalized)
setCoverPreview(previewUrlRef.current)
}
}
@@ -1,4 +1,4 @@
// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-054
// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-056
import type { Mock } from 'vitest';
import { act, render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -47,9 +47,11 @@ function asShareOwner(): void {
server.use(http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: null })));
}
/** A browser on https, where the async clipboard API is the path taken. */
function mockClipboard(): Mock<(text: string) => Promise<void>> {
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true });
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true, writable: true });
return writeText;
}
@@ -80,6 +82,7 @@ afterEach(() => {
delete window.__addToast;
vi.useRealTimers();
vi.restoreAllMocks();
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true });
});
describe('TripMembersModal', () => {
@@ -272,11 +275,7 @@ describe('TripMembersModal', () => {
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
const writeText = mockClipboard();
server.use(
http.get('/api/trips/1/share-link', () =>
@@ -616,9 +615,12 @@ describe('TripMembersModal', () => {
vi.useFakeTimers();
fireEvent.click(copyBtn);
// The copy is awaited now, so the badge lands a microtask after the click.
await act(async () => {});
expect(screen.getByText('Copied')).toBeInTheDocument();
// A second copy replaces the pending reset rather than stacking timers.
fireEvent.click(screen.getByText('Copied').closest('button')!);
await act(async () => {});
act(() => { vi.advanceTimersByTime(2000); });
expect(screen.getByText('Copy')).toBeInTheDocument();
@@ -980,6 +982,42 @@ describe('TripMembersModal', () => {
expect(screen.queryByTitle('Remove access')).not.toBeInTheDocument();
});
// A self-hosted install served over plain HTTP has no navigator.clipboard, and
// the unguarded call threw before the button ever showed feedback.
it('FE-COMP-MEMBERS-055: the share link copies through execCommand without a clipboard API', async () => {
const user = userEvent.setup();
asShareOwner();
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true, writable: true });
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true });
const execCommand = vi.fn(() => true);
Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true });
server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: 'tok55' })));
render(<TripMembersModal {...defaultProps} />);
await user.click((await screen.findByText('Copy')).closest('button')!);
expect(execCommand).toHaveBeenCalledWith('copy');
await screen.findByText('Copied');
expect(document.querySelector('textarea')).toBeNull();
});
it('FE-COMP-MEMBERS-056: the invite link copies through execCommand without a clipboard API', async () => {
const user = userEvent.setup();
asShareOwner();
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true, writable: true });
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true });
const execCommand = vi.fn(() => true);
Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true });
server.use(http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'inv56' })));
render(<TripMembersModal {...defaultProps} />);
await user.click((await screen.findByText('Copy')).closest('button')!);
expect(execCommand).toHaveBeenCalledWith('copy');
await screen.findByText('Copied');
expect(document.querySelector('textarea')).toBeNull();
});
it('FE-COMP-MEMBERS-054: the invite-link copied badge resets after two seconds', async () => {
asShareOwner();
const writeText = mockClipboard();
@@ -990,6 +1028,7 @@ describe('TripMembersModal', () => {
vi.useFakeTimers();
fireEvent.click(copyBtn);
await act(async () => {});
expect(screen.getByText('Copied')).toBeInTheDocument();
act(() => { vi.advanceTimersByTime(2000); });
@@ -9,6 +9,7 @@ import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check,
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import CustomSelect from '../shared/CustomSelect'
import { copyText } from '../../utils/clipboard'
interface AvatarProps {
username: string
@@ -78,13 +79,12 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
} catch { toast.error(t('common.error')) }
}
const handleCopy = () => {
if (shareUrl) {
navigator.clipboard.writeText(shareUrl)
setCopied(true)
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
}
const handleCopy = async () => {
if (!shareUrl) return
if (!(await copyText(shareUrl))) return
setCopied(true)
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
}
if (loading) return null
@@ -201,8 +201,8 @@ function TripInviteLinkSection({ tripId, t }: { tripId: number; t: (key: string,
finally { setBusy(false) }
}
const copy = () => {
navigator.clipboard.writeText(inviteUrl)
const copy = async () => {
if (!(await copyText(inviteUrl))) return
setCopied(true)
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
+1 -1
View File
@@ -98,7 +98,7 @@ interface StatCardProps {
canEdit: boolean
selectedYear: number
isShiftedYear: boolean
onSave: (userId: number, year: number, days: number) => Promise<void>
onSave: (year: number, days: number, targetUserId: number) => Promise<void>
t: TranslationFn
}
@@ -1,7 +1,9 @@
// FE-COMP-VCYREG-001 to FE-COMP-VCYREG-007
// FE-COMP-VCYREG-001 to FE-COMP-VCYREG-009
import { describe, it, expect, beforeEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions'
function holidaysRespond(rows: unknown) {
@@ -14,6 +16,7 @@ function regionsRespond(body: unknown) {
beforeEach(() => {
server.resetHandlers()
resetAllStores()
})
describe('fetchRegionOptions', () => {
@@ -80,7 +83,8 @@ describe('fetchSchoolHolidayRegionOptions', () => {
])
})
it('FE-COMP-VCYREG-007: a subdivision country prefers the German name and falls back to the code', async () => {
it('FE-COMP-VCYREG-007: a subdivision country takes the name in the active language and falls back to the code', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, language: 'de' } })
regionsRespond({
groups: [],
subdivisions: [
@@ -95,6 +99,19 @@ describe('fetchSchoolHolidayRegionOptions', () => {
])
})
it('FE-COMP-VCYREG-009: a language the data has no name for falls back to English', async () => {
regionsRespond({
groups: [],
subdivisions: [
{ code: 'DE-BY', name: [{ language: 'DE', text: 'Bayern' }, { language: 'EN', text: 'Bavaria' }] },
],
})
expect(await fetchSchoolHolidayRegionOptions('DE', 'fr')).toEqual([
{ value: 'DE-BY', label: 'Bavaria' },
])
})
it('FE-COMP-VCYREG-008: a failing region request degrades to no regions', async () => {
server.use(http.get('/api/addons/vacay/school-holidays/regions/:country', () =>
HttpResponse.json({ error: 'down' }, { status: 500 })))
+18 -8
View File
@@ -1,4 +1,5 @@
import apiClient from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
// Loads the subdivision (state/region) options for a holiday-calendar country.
@@ -48,31 +49,40 @@ type OpenHolidayOption = {
children?: OpenHolidayOption[] | null
}
function localizedName(names: LocalizedName[] | undefined, fallback: string): string {
return names?.find(n => n.language?.toUpperCase() === 'DE')?.text
// OpenHolidays ships every region name in several languages, so pick the one the
// user actually reads before falling back to English.
function localizedName(names: LocalizedName[] | undefined, fallback: string, lang: string): string {
const want = lang.slice(0, 2).toUpperCase()
return names?.find(n => n.language?.toUpperCase() === want)?.text
|| names?.find(n => n.language?.toUpperCase() === 'EN')?.text
|| names?.[0]?.text
|| fallback
}
function flattenOptions(items: OpenHolidayOption[] | undefined, prefix = ''): { value: string; label: string }[] {
function flattenOptions(items: OpenHolidayOption[] | undefined, lang: string, prefix = ''): { value: string; label: string }[] {
const result: { value: string; label: string }[] = []
for (const item of items ?? []) {
const label = localizedName(item.name, item.shortName || item.code)
const label = localizedName(item.name, item.shortName || item.code, lang)
result.push({ value: item.code, label: prefix ? `${prefix} / ${label}` : label })
result.push(...flattenOptions(item.children ?? undefined, prefix ? `${prefix} / ${label}` : label))
result.push(...flattenOptions(item.children ?? undefined, lang, prefix ? `${prefix} / ${label}` : label))
}
return result
}
export async function fetchSchoolHolidayRegionOptions(country: string): Promise<{ value: string; label: string }[]> {
// Read outside React: the two settings screens hand this function nothing but the
// country, and threading the language through both of them buys nothing.
function activeLanguage(): string {
return useSettingsStore.getState().settings.language || 'en'
}
export async function fetchSchoolHolidayRegionOptions(country: string, lang = activeLanguage()): Promise<{ value: string; label: string }[]> {
const config = SCHOOL_HOLIDAY_COUNTRY_CONFIG[country]
if (!config || config.strategy === 'country') return []
try {
const r = await apiClient.get(`/addons/vacay/school-holidays/regions/${country}`)
if (config.strategy === 'groups') {
return flattenOptions(r.data.groups)
return flattenOptions(r.data.groups, lang)
.map(opt => ({
value: `${country}|group:${opt.value}`,
label: opt.label,
@@ -80,7 +90,7 @@ export async function fetchSchoolHolidayRegionOptions(country: string): Promise<
.sort((a, b) => a.label.localeCompare(b.label))
}
return flattenOptions(r.data.subdivisions)
return flattenOptions(r.data.subdivisions, lang)
.map(opt => ({ value: opt.value, label: opt.label }))
.sort((a, b) => a.label.localeCompare(b.label))
} catch {
@@ -126,6 +126,26 @@ describe('CustomDatePicker', () => {
expect(onChange).toHaveBeenCalledWith('2026-07-17');
});
it('FE-COMP-DATEPICKER-031: text input rejects a day the month does not have', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="" onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByPlaceholderText('DD.MM.YYYY');
fireEvent.change(input, { target: { value: '31.02.2026' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).not.toHaveBeenCalled();
});
it('FE-COMP-DATEPICKER-032: text input still accepts 29 February in a leap year', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="" onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /enter date manually/i }));
const input = screen.getByPlaceholderText('DD.MM.YYYY');
fireEvent.change(input, { target: { value: '29.02.2024' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith('2024-02-29');
});
it('FE-COMP-DATEPICKER-014: Escape in text input cancels text mode', async () => {
const user = userEvent.setup();
render(<CustomDatePicker value="" onChange={onChange} />);
@@ -240,6 +240,10 @@ export function CustomDatePicker({
}
const year = y < 100 ? 2000 + y : y;
if (m < 1 || m > 12 || d < 1 || d > 31) return;
// 31.02. parses fine as numbers but is no day at all; without the round-trip it would
// travel on as '2026-02-31' and come back out of new Date() as 3 March.
const probe = new Date(Date.UTC(year, m - 1, d));
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== m - 1 || probe.getUTCDate() !== d) return;
const iso = `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
if (isOutOfRange(iso)) return;
onChange(iso);
+7
View File
@@ -51,6 +51,13 @@ export interface QueuedMutation {
conflictServer?: unknown;
/** When the conflict was detected (for ordering / display). */
conflictAt?: number;
/**
* When the row was marked 'syncing'. Only meaningful while it is — the flush
* that set it clears the row on success or moves it off 'syncing' on failure.
* A stamp that outlives its flush is how a killed tab is recognised on the
* next one (see mutationQueue's STUCK_SYNCING_MS).
*/
syncingSince?: number;
}
export interface SyncMeta {
+55 -1
View File
@@ -1,4 +1,5 @@
// FE-HOOK-GEO-001 to FE-HOOK-GEO-023
// FE-HOOK-GEO-001 to FE-HOOK-GEO-025
import { StrictMode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { GeoOnceError, getCurrentPositionOnce, useGeolocation } from './useGeolocation';
@@ -221,6 +222,26 @@ describe('useGeolocation', () => {
expect(clearWatch).toHaveBeenCalledWith(7);
});
it('FE-HOOK-GEO-015b: the updater form sees the freshest mode and starts one watch', async () => {
// The side effects run outside the state updater (React may invoke an
// updater more than once per commit, and startWatch awaits the iOS
// orientation prompt before it records the watch id), so the previous mode
// comes from a ref that setMode keeps in step within a batch.
const requestPermission = vi.fn().mockResolvedValue('granted');
vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission }));
const { result } = renderHook(() => useGeolocation(), { wrapper: StrictMode });
await act(async () => {
result.current.setMode('show');
result.current.setMode(prev => (prev === 'show' ? 'follow' : 'off'));
await Promise.resolve();
});
expect(result.current.mode).toBe('follow');
expect(watchPosition).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-GEO-016: asks iOS for orientation permission and proceeds either way', async () => {
const requestPermission = vi.fn().mockResolvedValue('denied');
vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission }));
@@ -251,6 +272,39 @@ describe('useGeolocation', () => {
expect(() => act(() => result.current.setMode('off'))).not.toThrow();
});
it('FE-HOOK-GEO-024: stopping while the iOS prompt is open leaves no watch behind', async () => {
let grant!: () => void;
const requestPermission = vi.fn(() => new Promise<string>(resolve => { grant = () => resolve('granted'); }));
vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission }));
const { result } = renderHook(() => useGeolocation());
act(() => result.current.setMode('show'));
act(() => result.current.setMode('off'));
await act(async () => { grant(); await new Promise(r => setTimeout(r, 0)); });
expect(watchPosition).not.toHaveBeenCalled();
});
it('FE-HOOK-GEO-025: stop and start during the iOS prompt still ends with one watch', async () => {
const grants: ((v: string) => void)[] = [];
const requestPermission = vi.fn(() => new Promise<string>(resolve => { grants.push(resolve); }));
vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission }));
const { result } = renderHook(() => useGeolocation());
act(() => result.current.setMode('show'));
act(() => result.current.setMode('off'));
act(() => result.current.setMode('show'));
await act(async () => { grants.forEach(g => g('granted')); await new Promise(r => setTimeout(r, 0)); });
// Both runs got past the prompt; only the newer one may subscribe, or the older
// watch keeps running with its id overwritten.
expect(requestPermission).toHaveBeenCalledTimes(2);
expect(watchPosition).toHaveBeenCalledTimes(1);
expect(result.current.mode).toBe('show');
});
it('FE-HOOK-GEO-019: stops the watch when the component unmounts', async () => {
const { result, unmount } = renderHook(() => useGeolocation());
await act(() => result.current.cycleMode());
+35 -11
View File
@@ -93,12 +93,25 @@ export function getCurrentPositionOnce(
export function useGeolocation(): UseGeolocationReturn {
const [position, setPosition] = useState<GeoPosition | null>(null)
const [mode, setModeState] = useState<TrackingMode>('off')
// What setMode's updater form reads instead of a state updater's argument:
// React may run an updater more than once per commit, and subscribing from
// inside one would leak a watch (see setMode below).
const modeRef = useRef<TrackingMode>(mode)
modeRef.current = mode
const [error, setError] = useState<string | null>(null)
const watchIdRef = useRef<number | null>(null)
// True between the start of startWatch and the watchPosition call it awaits.
const startingRef = useRef(false)
// Bumped by every start and by every stop, so a run parked on the iOS prompt can
// tell it has been superseded — clearing startingRef alone can't, a start that came
// after the stop has set it again by then.
const startRunRef = useRef(0)
const orientationHandlerRef = useRef<((e: DeviceOrientationEvent) => void) | null>(null)
const headingRef = useRef<number | null>(null)
const stopWatch = useCallback(() => {
startingRef.current = false
startRunRef.current++
if (watchIdRef.current !== null) {
try { navigator.geolocation.clearWatch(watchIdRef.current) } catch { /* noop */ }
watchIdRef.current = null
@@ -116,6 +129,12 @@ export function useGeolocation(): UseGeolocationReturn {
setError('Geolocation is not supported in this browser')
return false
}
// Already watching, or still waiting on the iOS prompt below: a second
// subscription would overwrite watchIdRef and leave the first one running
// with nobody left holding its id.
if (startingRef.current || watchIdRef.current !== null) return true
startingRef.current = true
const run = ++startRunRef.current
setError(null)
// iOS: ask for orientation permission up front; on Android and desktop
@@ -129,6 +148,9 @@ export function useGeolocation(): UseGeolocationReturn {
}
} catch { /* older webkit throws — ignore and proceed */ }
}
// Stopped (or restarted) while the prompt was open: subscribing now would leave a
// watch running that nobody holds the id of any more.
if (startRunRef.current !== run) return false
// Device orientation → compass heading. `alpha` is rotation around the
// Z-axis (0 = facing magnetic north on most devices). The webkit-only
@@ -157,6 +179,7 @@ export function useGeolocation(): UseGeolocationReturn {
window.addEventListener('deviceorientationabsolute', onOrientation as EventListener)
window.addEventListener('deviceorientation', onOrientation as EventListener)
startingRef.current = false
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => {
setPosition({
@@ -189,17 +212,18 @@ export function useGeolocation(): UseGeolocationReturn {
}, [stopWatch])
const setMode = useCallback((m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => {
setModeState(prev => {
const next = typeof m === 'function' ? m(prev) : m
if (next === 'off') {
stopWatch()
setPosition(null)
} else if (watchIdRef.current === null) {
// started externally but no watch yet — start it
startWatch()
}
return next
})
const next = typeof m === 'function' ? m(modeRef.current) : m
// Kept in step here as well, so two setMode calls batched into one commit
// still see each other's result.
modeRef.current = next
setModeState(next)
if (next === 'off') {
stopWatch()
setPosition(null)
} else if (watchIdRef.current === null) {
// started externally but no watch yet — start it
void startWatch()
}
}, [startWatch, stopWatch])
const cycleMode = useCallback(async () => {
+14 -5
View File
@@ -205,19 +205,28 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
polyline.push(c)
}
}
for (let i = 0; i < run.length - 1; i++) {
// Neighbouring legs that resolve to the SAME mode travel as one
// multi-waypoint request — the router answers with one leg per pair, so
// the per-connector segments stay exactly as they were, and a day without
// per-leg overrides is back to a single call per run.
let i = 0
while (i < run.length - 1) {
const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode)
const straight = (): [number, number][] => [[run[i].lat, run[i].lng], [run[i + 1].lat, run[i + 1].lng]]
let end = i + 1
while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
const chunk = run.slice(i, end + 1)
const straight = (): [number, number][] => chunk.map(p => [p.lat, p.lng] as [number, number])
try {
const r = await calculateRouteWithLegs([{ lat: run[i].lat, lng: run[i].lng }, { lat: run[i + 1].lat, lng: run[i + 1].lng }], { signal: controller.signal, profile: mode, tripId, dayId })
const r = await calculateRouteWithLegs(chunk.map(p => ({ lat: p.lat, lng: p.lng })), { signal: controller.signal, profile: mode, tripId, dayId })
pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight())
if (r.legs[0]) allLegs.push({ ...r.legs[0], mode })
for (const leg of r.legs) allLegs.push({ ...leg, mode })
if (r.vias) allVias.push(...r.vias)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') throw err
// Routing failed for this leg — fall back to a straight line, no times.
// Routing failed for these legs — fall back to straight lines, no times.
pushCoords(straight())
}
i = end
}
if (polyline.length >= 2) polylines.push(polyline)
}
+4 -2
View File
@@ -133,7 +133,9 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
let val: string = (strings[key] ?? en[key] ?? key) as string
if (params) {
Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
// Function replacement: a value carrying `$&` or `$1` (a trip named
// "A$&B") would otherwise be expanded as a replacement pattern.
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), () => String(v))
})
}
return val
@@ -145,7 +147,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
Object.entries(params).forEach(([k, v]) => {
// Escape BEFORE substitution so a user-controlled value with `<` or
// `&` cannot break out of the surrounding template's markup.
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), escapeHtml(String(v)))
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), () => escapeHtml(String(v)))
})
}
// Then re-sanitise the fully-built string: even if a translator ships a
+6 -4
View File
@@ -230,12 +230,14 @@ export default function MAdmin() {
}}
collabFeatures={collabFeatures}
onToggleCollabFeature={async (key: string) => {
const next = { ...collabFeatures, [key]: !collabFeatures[key] }
setCollabFeatures(next)
const previous = collabFeatures[key]
setCollabFeatures({ ...collabFeatures, [key]: !previous })
try {
await adminApi.updateCollabFeatures({ [key]: next[key] })
await adminApi.updateCollabFeatures({ [key]: !previous })
} catch {
setCollabFeatures(collabFeatures)
// Only this key rolls back — a slower request must not undo a toggle
// the admin made in the meantime.
setCollabFeatures(prev => ({ ...prev, [key]: previous }))
}
}}
/>
@@ -89,6 +89,13 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
setError('')
}, [trip, open])
// The local file preview is a blob url; release it once a new cover replaces it
// or the sheet goes away. Server and Unsplash urls are left alone.
useEffect(() => {
if (!coverPreview?.startsWith('blob:')) return
return () => { URL.revokeObjectURL(coverPreview) }
}, [coverPreview])
// Moving the start keeps the trip length (same rule as TripFormModal).
const changeStart = (value: string) => {
if (value && endDate && startDate && endDate >= startDate) {
@@ -11,7 +11,7 @@ import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyStore } from '../../../store/journeyStore'
import type { JourneyEntry, GalleryPhoto } from '../../../store/journeyStore'
import { useAuthStore } from '../../../store/authStore'
import { journeyApi, addonsApi } from '../../../api/client'
import { journeyApi, addonsApi, memoriesApi } from '../../../api/client'
import { normalizeImageFiles } from '../../../utils/convertHeic'
import { isVideoFile } from '../../../utils/videoPoster'
import { getApiErrorMessage } from '../../../types'
@@ -135,7 +135,8 @@ export default function MJourneyDetail() {
const [uploading, setUploading] = useState(false)
useEffect(() => {
(async () => {
let active = true
;(async () => {
try {
const addonsData = await addonsApi.enabled()
const enabled = (addonsData.addons || []).filter(
@@ -143,14 +144,19 @@ export default function MJourneyDetail() {
)
const connected: { id: string; name: string }[] = []
for (const p of enabled) {
// The probes run one after another, so leaving the screen has to stop
// the queue rather than let every remaining provider be asked anyway.
if (!active) return
try {
const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' })
if (res.ok && (await res.json()).connected) connected.push({ id: p.id, name: p.name })
// Same probe the desktop gallery uses, so a NAS cannot read as
// connected on one surface and not the other.
if ((await memoriesApi.status(p.id)).connected) connected.push({ id: p.id, name: p.name })
} catch { /* provider stays hidden */ }
}
setAvailableProviders(connected)
if (active) setAvailableProviders(connected)
} catch { /* no providers */ }
})()
return () => { active = false }
}, [])
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -101,6 +101,11 @@ export default function MJourneyEntrySheet({
[addons],
)
// Minting the preview inside the tile markup would hand out a fresh blob URL on
// every keystroke in the story field, and nothing would ever release them.
const pendingPreviews = useMemo(() => pendingFiles.map(f => URL.createObjectURL(f)), [pendingFiles])
useEffect(() => () => { pendingPreviews.forEach(url => URL.revokeObjectURL(url)) }, [pendingPreviews])
useEffect(() => {
if (readOnly) return
// App.tsx loads the addon list on boot, so this only reacts to it. Kicking off
@@ -536,13 +541,23 @@ export default function MJourneyEntrySheet({
<button
type="button"
onClick={() => {
setPhotos(prev => {
const next = [...prev]
const [moved] = next.splice(idx, 1)
next.unshift(moved)
next.forEach((ph, i) => { journeyApi.updatePhoto(ph.id, { sort_order: i }).catch(() => {}) })
return next
})
// The PATCHes stay outside the updater — StrictMode invokes it
// twice in dev and would send the whole batch a second time.
const prevOrder = photos
const next = [...photos]
const [moved] = next.splice(idx, 1)
next.unshift(moved)
setPhotos(next)
// Same as the desktop editor: the order is shared with the other
// members, the share view and the PDF, so a run the server refused
// outright goes back instead of passing for saved.
void (async () => {
const results = await Promise.allSettled(next.map((ph, i) => journeyApi.updatePhoto(ph.id, { sort_order: i })))
const rejected = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[]
if (rejected.length === 0) return
toast.error(getApiErrorMessage(rejected[0].reason, t('common.error')))
if (rejected.length === results.length) setPhotos(prevOrder)
})()
}}
className="absolute bottom-[3px] left-[3px] rounded-full bg-black/60 px-[6px] py-[1px] font-geist text-[0.5rem] font-bold text-white"
>
@@ -568,9 +583,9 @@ export default function MJourneyEntrySheet({
)}
</div>
))}
{pendingFiles.map((f, i) => (
{pendingFiles.map((_, i) => (
<div key={`pending-${i}`} className="relative h-16 w-16 overflow-hidden rounded-[13px]">
<img src={URL.createObjectURL(f)} alt="" className="h-full w-full object-cover" />
<img src={pendingPreviews[i]} alt="" className="h-full w-full object-cover" />
<button
type="button"
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
@@ -14,6 +14,7 @@ import { journeyApi } from '../../../api/client'
import { useJourneyStore } from '../../../store/journeyStore'
import type { JourneyDetail } from '../../../store/journeyStore'
import { normalizeImageFile } from '../../../utils/convertHeic'
import { copyText } from '../../../utils/clipboard'
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { journeyCoverSrc } from './mobileJourneyMeta'
@@ -131,6 +132,12 @@ export default function MJourneySettingsSheet({
}
}
const copyShareUrl = async () => {
if (!(await copyText(shareUrl))) return
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const toggleSharePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
if (!shareLink) return
const updated = { ...shareLink, [key]: !shareLink[key] }
@@ -333,11 +340,7 @@ export default function MJourneySettingsSheet({
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] text-m-muted">{shareUrl}</span>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(shareUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
onClick={copyShareUrl}
className="flex-none rounded-full bg-m-act px-3 py-[5px] font-geist text-[0.65625rem] font-bold text-m-actfg"
>
{copied ? t('journey.share.copied') : t('journey.share.copy')}
@@ -18,6 +18,7 @@ import {
} from 'lucide-react'
import { useNavigate, useSearchParams } from 'react-router'
import { startRegistration } from '@simplewebauthn/browser'
import { escapeHtml } from '@trek/shared'
import { useTranslation } from '../../../i18n'
import { useAuthStore } from '../../../store/authStore'
import { useToast } from '../../../components/shared/Toast'
@@ -136,7 +137,7 @@ export default function MSettingsAccount() {
const printBackupCodes = () => {
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()
@@ -85,6 +85,9 @@ export default function MSettingsAppearance() {
const [cfg, setCfg] = useState<AppearanceConfig>(() => normalizeAppearance(settings.appearance))
const persistTimer = useRef<number | undefined>(undefined)
// What the pending timer would have written, so leaving the screen inside the
// debounce window still saves instead of silently dropping the change.
const pendingWrite = useRef<AppearanceConfig | null>(null)
// Re-sync when settings change elsewhere (server reconcile / another tab).
useEffect(() => {
@@ -92,8 +95,11 @@ export default function MSettingsAppearance() {
}, [settings.appearance])
useEffect(() => () => {
if (persistTimer.current) window.clearTimeout(persistTimer.current)
}, [])
if (!persistTimer.current) return
window.clearTimeout(persistTimer.current)
// The component is gone, so a failure has nowhere to be shown.
if (pendingWrite.current) updateSetting('appearance', pendingWrite.current).catch(() => {})
}, [updateSetting])
const isDark =
settings.dark_mode === true ||
@@ -106,7 +112,9 @@ export default function MSettingsAppearance() {
setCfg(next)
applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false })
if (persistTimer.current) window.clearTimeout(persistTimer.current)
pendingWrite.current = next
persistTimer.current = window.setTimeout(() => {
pendingWrite.current = null
updateSetting('appearance', next).catch((e: unknown) =>
toast.error(e instanceof Error ? e.message : t('common.error')),
)
@@ -127,7 +127,13 @@ export default function MSettingsNotifications() {
try {
await notificationsApi.updatePreferences(updated)
} catch {
setMatrix((m) => (m ? { ...m, preferences: matrix.preferences } : m))
// Only this cell rolls back — restoring the whole snapshot would also undo a
// toggle the user made while this request was in flight.
setMatrix((m) => (m ? {
...m,
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: current } },
} : m))
toast.error(t('common.error'))
} finally {
setSaving(false)
}
@@ -417,8 +417,8 @@ function TimelineHeader({ tl, dayLabel, openLabel, onOpenDay }: {
/** Icon-coded like the demo, colour-tinted per the audit (green in / red out). */
function HotelChipIcon({ variant }: { variant: 'checkout' | 'checkin' | 'stay' }) {
if (variant === 'checkout') return <LogOut size={12} strokeWidth={2.2} className="text-[#D6273B]" />
if (variant === 'checkin') return <LogIn size={12} strokeWidth={2.2} className="text-[#3BA55C]" />
if (variant === 'checkout') return <LogOut size={12} strokeWidth={2.2} className="text-[color:var(--m-st-danger)]" />
if (variant === 'checkin') return <LogIn size={12} strokeWidth={2.2} className="text-[color:var(--m-st-confirmed)]" />
return <BedDouble size={12} strokeWidth={2.2} className="text-m-muted" />
}
@@ -347,12 +347,12 @@ export function useMPlanTimeline(planner: TripPlanner) {
const activePlugins = usePluginStore(s => s.plugins)
const routeModeOptions = useMemo(() => {
const opts: Array<{ key: string; label: string }> = [
{ key: 'driving', label: 'Driving' },
{ key: 'walking', label: 'Walking' },
{ key: 'driving', label: t('mobileTrip.profileDriving') },
{ key: 'walking', label: t('mobileTrip.profileWalking') },
]
for (const p of activePlugins) for (const prof of p.routeProfiles ?? []) opts.push({ key: `plugin:${p.id}/${prof.id}`, label: prof.label })
return opts
}, [activePlugins])
}, [activePlugins, t])
// Set the mode of the leg leaving a stop — optimistic, then persisted; null clears
// the override back to the day default. Sticky against the whole-day picker.
@@ -12,6 +12,7 @@ import { useExchangeRates } from '../../../../hooks/useExchangeRates'
import { formatMoney, localizeAmountInput, cleanAmount } from '../../../../utils/formatters'
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
import { localToday } from '../../../../components/Planner/today'
import { calculateTicketShares, hasTicketSplit, NOTE_MAX, readTicketItems, readUserNote, splitEqualShares, writeTicketItems, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers'
import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel'
import { payersBalanced, rebalancePayers } from '../../../../components/Budget/CostsPanel.helpers'
@@ -72,7 +73,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
const [catOpen, setCatOpen] = useState(false)
const [note, setNote] = useState(() => readUserNote(editing))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
const [day, setDay] = useState(editing?.expense_date || localToday())
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : ''
if (prefill?.amount != null) return String(prefill.amount)
@@ -296,6 +297,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
<span
style={{
width: size, height: size, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient,
// theme-lint-disable — white initial on the member's tint, and the glyph scales with the avatar
color: '#fff', display: 'grid', placeItems: 'center', fontSize: size * 0.4, fontWeight: 700, flexShrink: 0, opacity: dim ? 0.45 : 1,
}}
>
@@ -608,7 +610,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span>
) : (
<span className={`font-semibold ${customBalanced ? 'text-[#16a34a]' : 'text-[color:var(--m-st-danger)]'}`}>
<span className={`font-semibold ${customBalanced ? 'text-[color:var(--m-st-confirmed)]' : 'text-[color:var(--m-st-danger)]'}`}>
{customBalanced
? t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })
: `${sym(currency)}${splitSum.toFixed(2)} / ${sym(currency)}${totalNum.toFixed(2)}`}
@@ -4,6 +4,7 @@ import MSheet from '../../../components/MSheet'
import { useAddonStore } from '../../../../store/addonStore'
import { useTranslation } from '../../../../i18n'
import { resolveDayId } from '../../../../utils/formatters'
import { parseReservationMetadata } from '../../../../utils/flightLegs'
import { typeToCostCategory } from '@trek/shared'
import CustomSelect from '../../../../components/shared/CustomSelect'
import CustomTimePicker from '../../../../components/shared/CustomTimePicker'
@@ -86,7 +87,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
const res = editingReservation
if (res) {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
const meta = parseReservationMetadata(res)
const rawEnd = res.reservation_end_time || ''
let endDate = '', endTime = rawEnd
if (rawEnd.includes('T')) { endDate = rawEnd.split('T')[0]; endTime = rawEnd.split('T')[1]?.slice(0, 5) || '' }
@@ -37,8 +37,14 @@ export default function MFileLightbox({ files, index, onIndexChange, onClose, t
const fileMimeType = file?.mime_type
useEffect(() => {
// Swiping is faster than the resource-token round trip, so a stale token must
// not overwrite the file the user is looking at now.
let cancelled = false
setImgSrc('')
if (fileUrl && !isVideo(fileMimeType)) getAuthUrl(fileUrl, 'download').then(setImgSrc)
if (fileUrl && !isVideo(fileMimeType)) {
getAuthUrl(fileUrl, 'download').then(url => { if (!cancelled) setImgSrc(url) })
}
return () => { cancelled = true }
}, [fileUrl, fileMimeType])
const hasPrev = index > 0
@@ -5,6 +5,7 @@ import { useAuthStore } from '../../../../store/authStore'
import { useTranslation } from '../../../../i18n'
import { avatarSrc } from '../../../../utils/avatarSrc'
import { formatDate } from '../../../../utils/formatters'
import { localToday } from '../../../../components/Planner/today'
import type { TodoItem, TripMember } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
import { TabScroller } from './tabChrome'
@@ -39,7 +40,9 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
const [editingItemId, setEditingItemId] = useState<number | null>(null)
const [creatingTask, setCreatingTask] = useState(false)
const today = useMemo(() => new Date().toISOString().slice(0, 10), [])
// The user's calendar day, not UTC's — an overdue task must turn overdue at
// the traveller's midnight, not eight hours either side of it.
const today = useMemo(() => localToday(), [])
const categories = useMemo(() => todoCategories(items), [items])
const counts = todoCounts(items, currentUserId, today)
const rows = useMemo(
@@ -215,8 +215,12 @@ export interface CsvBuildOptions {
/** Ports `CostsPanel.tsx`'s `handleExportCsv` row-building 1:1; the Blob/download is a DOM concern left to the caller. */
export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { filename: string; content: string } {
const sep = ';'
// A cell starting with =, +, -, @, TAB or CR is evaluated as a formula by Excel
// and Sheets, and the name/note columns are free text any trip member can write.
// Mirrors the same guard in `CostsPanel.tsx`'s exporter.
const esc = (v: unknown) => {
const s = String(v ?? '')
let s = String(v ?? '')
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s
return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s
}
const fmtDate = (iso: string) => {
+38 -3
View File
@@ -1,4 +1,4 @@
import { http, HttpResponse } from 'msw';
import { delay, http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { buildAdmin } from '../../tests/helpers/factories';
import { server } from '../../tests/helpers/msw/server';
@@ -27,16 +27,21 @@ vi.mock('../components/Admin/AddonManager', () => ({
default: ({
bagTrackingEnabled,
onToggleBagTracking,
collabFeatures,
onToggleCollabFeature,
}: {
bagTrackingEnabled: boolean;
onToggleBagTracking: () => void;
collabFeatures: Record<string, boolean>;
onToggleCollabFeature: (key: string) => void;
}) => (
<div data-testid="addon-manager">
<span data-testid="bag-tracking-state">{String(bagTrackingEnabled)}</span>
<span data-testid="collab-chat-state">{String(collabFeatures?.chat)}</span>
<span data-testid="collab-notes-state">{String(collabFeatures?.notes)}</span>
<button onClick={() => onToggleBagTracking()}>toggle bag tracking</button>
<button onClick={() => onToggleCollabFeature('chat')}>toggle chat</button>
<button onClick={() => onToggleCollabFeature('notes')}>toggle notes</button>
</div>
),
}));
@@ -1569,8 +1574,38 @@ describe('AdminPage', () => {
fireEvent.click(screen.getByRole('button', { name: /toggle chat/i }));
// The rollback re-renders the stub; the addon panel stays mounted either way
await waitFor(() => expect(screen.getByTestId('addon-manager')).toBeInTheDocument());
await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
});
// A snapshot-wide rollback would undo the second toggle as well, leaving the
// panel disagreeing with the server until a reload.
it('a failing collab toggle keeps a second toggle made while it was in flight', async () => {
server.use(
http.get('/api/admin/collab-features', () =>
HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true })
),
http.put('/api/admin/collab-features', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
if ('chat' in body) {
await delay(30);
return HttpResponse.json({}, { status: 500 });
}
return HttpResponse.json({ success: true });
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
render(<AdminPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /^addons$/i }));
await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
fireEvent.click(screen.getByRole('button', { name: /toggle chat/i }));
fireEvent.click(screen.getByRole('button', { name: /toggle notes/i }));
await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
expect(screen.getByTestId('collab-notes-state')).toHaveTextContent('false');
});
});
+9 -3
View File
@@ -161,9 +161,15 @@ function AdminPageDesktop(): React.ReactElement {
setBagTrackingEnabled(next)
try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) }
}} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => {
const next = { ...collabFeatures, [key]: !collabFeatures[key] }
setCollabFeatures(next)
try { await adminApi.updateCollabFeatures({ [key]: next[key] }) } catch { setCollabFeatures(collabFeatures) }
const previous = collabFeatures[key]
setCollabFeatures({ ...collabFeatures, [key]: !previous })
try {
await adminApi.updateCollabFeatures({ [key]: !previous })
} catch {
// Only this key rolls back — a slower request must not undo a toggle
// the admin made in the meantime.
setCollabFeatures(prev => ({ ...prev, [key]: previous }))
}
}} />
</div>
)}

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