Merge branch 'dev' into feat/comaps-integration

# Conflicts:
#	client/src/components/Planner/DayPlanSidebar.tsx
This commit is contained in:
Konstantinos Thermos
2026-08-14 15:05:00 +03:00
367 changed files with 8734 additions and 1431 deletions
@@ -68,6 +68,38 @@ jobs:
return;
}
// Dependabot cannot be pointed at dev. `target-branch` in dependabot.yml
// applies to version updates only; security updates always open against
// the default branch, and security updates are all this repository gets
// (there is no dependabot.yml). Labelling them meant close-stale-wrong-branch
// shut them 24 hours later, which is how 13 of them died. Retarget instead:
// a bot cannot click "Edit" the way the comment below asks a human to.
if (context.payload.pull_request.user.login === 'dependabot[bot]') {
try {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
base: 'dev',
});
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
console.log('Retargeted the Dependabot PR onto dev.');
return;
} catch (err) {
// Retargeting can fail when the branch was cut from main and dev has
// moved on. Fall through to the normal path so a human sees it rather
// than the check passing on a PR that is still aimed at main.
console.log(`Could not retarget the Dependabot PR: ${err.message}`);
}
}
// Base is main — check if this user is a maintainer
let permission = 'none';
try {
+1
View File
@@ -114,6 +114,7 @@ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/
chown -h node:node /app/server/uploads /app/server/data
ENV NODE_ENV=production
ENV NODE_USE_ENV_PROXY=1
ENV PORT=3000
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
+105
View File
@@ -0,0 +1,105 @@
import { test, expect, devices } from '@playwright/test'
import { dismissSystemNotices } from './helpers'
// Phone regression guard for #1809: the dashboard must scroll the DOCUMENT.
//
// iOS Safari minimises its address bar only in reaction to the root scroller
// moving. V4 blocked that twice over: html was globally overflow:hidden and the
// mobile shell added a scroll container of its own, so the gesture never
// reached the viewport and the bar kept ~1/6th of the screen forever. The bar
// itself is not observable from page context, but the invariant it hangs off is:
// document.scrollingElement has to move.
//
// Needs WebKit (`npx playwright install webkit`). playwright.config.ts has no
// webkit project, the engine comes from this file-level device (defaultBrowserType
// 'webkit'), same as e2e/ipad-scroll-1432.spec.ts. WebKit is the point here, not
// a nicety: viewport propagation and the toolbar behaviour are Safari's.
//
// The file sorts last in the e2e run, so the trips it seeds cannot show up in
// another spec's dashboard assertions.
test.use({ ...devices['iPhone 14'] })
function readScroll() {
const el = document.scrollingElement as HTMLElement
return {
top: el.scrollTop,
scrollHeight: el.scrollHeight,
viewport: window.innerHeight,
htmlOverflow: getComputedStyle(document.documentElement).overflowY,
}
}
test('#1809 iPhone: flow screens scroll the document, full-screen ones do not', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
// The context has to be the one from the report: phone width, coarse pointer.
// If either is wrong, nothing below proves anything.
const env = await page.evaluate(() => ({
width: window.innerWidth,
coarse: window.matchMedia('(pointer: coarse)').matches,
}))
expect(env.width, 'iPhone sits below the 768px phone breakpoint').toBeLessThan(768)
expect(env.coarse, 'iPhone reports a coarse primary pointer').toBe(true)
// Enough trips for the dashboard to overflow a phone screen.
const stamp = Date.now()
for (let i = 1; i <= 20; i++) {
const res = await page.request.post('/api/trips', {
data: { title: `URL bar ${stamp} ${i}`, start_date: '2099-06-01', end_date: '2099-06-10' },
})
expect(res.ok(), `seed trip ${i}`).toBeTruthy()
}
await page.reload()
await dismissSystemNotices(page)
await expect(page.getByText(`URL bar ${stamp} 1`).first()).toBeVisible({ timeout: 20_000 })
// 1. The root scroller is unlocked and the page is longer than the viewport.
const before = await page.evaluate(readScroll)
expect(before.htmlOverflow, 'html must not lock the viewport scroller').not.toBe('hidden')
expect(before.scrollHeight, 'dashboard is longer than one viewport').toBeGreaterThan(before.viewport)
expect(before.top).toBe(0)
// 2. Scrolling moves the document, the signal Safari retracts its bar for.
await page.evaluate(() => window.scrollBy(0, 400))
const scrolled = await page.evaluate(readScroll)
expect(scrolled.top, 'the document itself scrolled').toBeGreaterThan(0)
// 3. The brand tile scrolls the page back up (it used to walk up to the shell
// container, which no longer exists).
await page.getByRole('button', { name: 'TREK' }).click()
await expect.poll(() => page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(0)
// 4. The shared body scroll lock: with the document as the scroller, an open
// sheet must freeze the page behind it and give the position back.
await page.evaluate(() => window.scrollBy(0, 300))
const locked = await page.evaluate(readScroll)
expect(locked.top).toBeGreaterThan(0)
await page.getByRole('button', { name: 'New Trip' }).click()
await expect(page.getByRole('dialog')).toBeVisible()
await page.evaluate(() => window.scrollBy(0, 300))
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toHaveCount(0)
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
await page.evaluate(() => window.scrollBy(0, 200))
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBeGreaterThan(locked.top)
// 5. Counter-probe: the map screens keep their own full-viewport layout. They
// deliberately do NOT scroll the document (and so keep the address bar), and
// the map must not collapse to zero height now that the shell has none.
await page.goto('/atlas')
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
const atlas = await page.evaluate(() => {
const map = document.querySelector('.leaflet-container') as HTMLElement
window.scrollBy(0, 400)
return {
top: document.scrollingElement!.scrollTop,
mapHeight: map.getBoundingClientRect().height,
}
})
expect(atlas.top, 'the atlas does not move the document').toBe(0)
expect(atlas.mapHeight, 'the atlas map keeps a real height').toBeGreaterThan(200)
})
+5
View File
@@ -17,6 +17,7 @@ import type {
CollectionInviteActionRequest,
CollectionInviteCancelRequest,
CollectionStatus,
CollectionSetStatusManyResponse,
Collection,
CollectionPlace,
CollectionLabel,
@@ -77,6 +78,10 @@ export const collectionsApi = {
postMultipart(`${base}/places/${pid}/image`, formData),
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
setStatusMany: (ids: number[], status: CollectionStatus): Promise<CollectionSetStatusManyResponse> =>
ax.post(`${base}/places/status-many`, { ids, status }).then((r: AxiosResponse) => r.data),
setStatusFromTrip: (tripId: number, placeIds: number[], status: CollectionStatus): Promise<CollectionSetStatusManyResponse> =>
ax.post(`${base}/places/status-from-trip`, { trip_id: tripId, place_ids: placeIds, status }).then((r: AxiosResponse) => r.data),
ratePlace: (pid: number, rating: number | null): Promise<CollectionPlace> =>
rating === null
? ax.delete(`${base}/places/${pid}/rating`).then((r: AxiosResponse) => r.data)
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems } from './CostsPanel.helpers'
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
describe('splitCents', () => {
it('splits evenly when it divides cleanly', () => {
@@ -132,3 +132,47 @@ describe('readUserNote', () => {
expect(readUserNote(null)).toBe('')
})
})
// ── Receipt splits have to reconcile (#1382) ─────────────────────────────────
describe('calculateTicketShares', () => {
const line = (price: string, parts: number[]): TicketItem =>
({ id: price + parts.join(), name: 'Line', price, participants: new Set(parts) })
const shareSum = (shares: Record<number, number>) =>
Object.values(shares).reduce((a, v) => a + Math.round(v * 100), 0)
it('splits each line among the people on it', () => {
const { shares, total } = calculateTicketShares([line('10', [1, 2]), line('6', [2])])
expect(shares).toEqual({ 1: 5, 2: 11 })
expect(total).toBe(16)
})
it('spreads a line nobody was ticked for across everyone on the receipt', () => {
// The service charge used to count toward the total and toward nobody's share,
// so the expense carried a permanent difference no settle-up could clear.
const { shares, total } = calculateTicketShares([
line('10', [1, 2]),
line('5', []),
line('x', [2]),
])
expect(shares).toEqual({ 1: 7.5, 2: 7.5 })
expect(total).toBe(15)
expect(shareSum(shares)).toBe(Math.round(total * 100))
})
it('hands out the remainder cents so the shares add back up to the total', () => {
const { shares, total } = calculateTicketShares([line('10', [1, 2, 3]), line('0.01', [])])
expect(shareSum(shares)).toBe(Math.round(total * 100))
expect(total).toBe(10.01)
})
it('splits nothing when the receipt has no participants anywhere', () => {
const { shares, total } = calculateTicketShares([line('10', []), line('5', [])])
expect(shares).toEqual({})
expect(total).toBe(15)
})
it('is empty for an empty receipt', () => {
expect(calculateTicketShares([])).toEqual({ shares: {}, total: 0 })
})
})
@@ -135,24 +135,36 @@ export function readUserNote(item: { note?: string | null } | null | undefined):
return note && !note.startsWith('TICKETJSON:') ? note : ''
}
/** Per-person totals for a receipt split line by line, plus the receipt total. */
/**
* Per-person totals for a receipt split line by line, plus the receipt total.
*
* Every cent on the receipt lands on somebody: a line nobody was ticked for is
* carried by everyone who appears on the receipt, so the shares add back up to
* the total. They used to count toward the total only, which left the expense
* with member debits that could never cancel the payer's credit — a permanent
* difference the settle-up view showed as debt but had no flow to clear (#1382).
* A receipt with no participants at all splits nothing; it is not saveable
* anyway, since every line needs someone on it.
*/
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
const shares: Record<number, number> = {}
let totalCents = 0
const everyone = [...new Set(items.flatMap(i => [...i.participants]))].sort((a, b) => a - b)
for (const item of items) {
const priceNum = parseFloat(item.price) || 0
const priceCents = Math.round(priceNum * 100)
totalCents += priceCents
const partIds = [...item.participants]
const n = partIds.length
const sortedPartIds = item.participants.size > 0
? [...item.participants].sort((a, b) => a - b)
: everyone
const n = sortedPartIds.length
if (n === 0) continue
const baseCents = Math.floor(priceCents / n)
const remainder = priceCents % n
const sortedPartIds = [...partIds].sort((a, b) => a - b)
const remainder = priceCents - baseCents * n
for (let i = 0; i < n; i++) {
const id = sortedPartIds[i]
@@ -1393,6 +1393,26 @@ describe('CostsPanel — expense modal', () => {
expect(posted).toMatchObject({ name: 'Hotel Astoria', category: 'accommodation', total_price: 240, reservation_id: 12 })
expect(onSaved).toHaveBeenCalled()
})
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
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: 'Louvre', category: 'activities', amount: 34, placeId: 7 }}
onClose={() => {}} onSaved={vi.fn()} />
)
await user.click(screen.getByRole('button', { name: 'Add expense' }))
await waitFor(() => expect(posted).toBeTruthy());
expect(posted).toMatchObject({ name: 'Louvre', category: 'activities', total_price: 34, place_id: 7 })
expect(posted).not.toHaveProperty('reservation_id')
})
})
describe('CostsPanel — remaining paths', () => {
@@ -1742,13 +1762,15 @@ describe('CostsPanel — split maths', () => {
expect(splitEqualShares(10, [{ user_id: 1 }, { user_id: 2 }, { user_id: 3 }], 1)).toEqual({ 1: 3.33, 2: 3.34, 3: 3.33 })
})
it('FE-W5COSTS-041: ticket items nobody is assigned to still count toward the total', () => {
it('FE-W5COSTS-041: a receipt line nobody is assigned to is carried by everyone on the receipt', () => {
const items: TicketItem[] = [
{ id: 'a', name: 'Apples', price: '10', participants: new Set([1, 2]) },
{ id: 'b', name: 'Service', price: '5', participants: new Set() },
{ id: 'c', name: 'Cake', price: 'x', participants: new Set([2]) },
]
expect(calculateTicketShares(items)).toEqual({ shares: { 1: 5, 2: 5 }, total: 15 })
// The service line used to count toward the total without landing on anyone,
// so the shares stayed a permanent 5.00 short of it (#1382).
expect(calculateTicketShares(items)).toEqual({ shares: { 1: 7.5, 2: 7.5 }, total: 15 })
})
})
+11 -2
View File
@@ -1000,6 +1000,8 @@ export interface ExpensePrefill {
category?: string
amount?: number
reservationId?: number
/** Set when the expense is being created from a place (#1298). */
placeId?: number
}
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
@@ -1232,7 +1234,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
.map(id => ({ user_id: id, amount: parseFloat(payerAmounts[id]) || 0 }))
.filter(p => p.amount > 0)
: payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : []
const memberList = [...participants].map(id => ({
// A receipt line can name somebody who is not ticked as a participant. Sending
// only the ticked set would drop their share, leaving the member sum short of
// total_price and handing the settlement a difference it can never clear (#1382).
const memberIds = splitMode === 'ticket'
? [...new Set([...participants, ...Object.keys(ticketInfo.shares).map(Number)])].sort((a, b) => a - b)
: [...participants]
const memberList = memberIds.map(id => ({
user_id: id,
amount: splitMode === 'custom'
? (parseFloat(customAmounts[id]) || 0)
@@ -1246,12 +1254,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
currency,
payers: payerList,
members: memberList,
member_ids: [...participants],
member_ids: memberIds,
expense_date: day || null,
total_price: totalNum,
note: note.trim() || null,
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
@@ -13,7 +13,10 @@ export function ChatMessages(props: any) {
<>
{/* Messages */}
{messages.length === 0 ? (
<EmptyState scene="chat" title={t('collab.chat.empty')} />
// flex-1, like the message list it stands in for: without it the empty
// state was only as tall as its own content and the composer rode up to
// sit right under the mascot, with the rest of the panel left blank.
<EmptyState scene="chat" title={t('collab.chat.empty')} className="min-h-0 flex-1" />
) : (
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
@@ -1,4 +1,4 @@
// FE-COMP-ADDPLACECOL-001 to FE-COMP-ADDPLACECOL-024
// FE-COMP-ADDPLACECOL-001 to FE-COMP-ADDPLACECOL-028
import React from 'react'
import type { Mock } from 'vitest'
import { http, HttpResponse } from 'msw'
@@ -163,13 +163,27 @@ describe('AddPlaceToCollectionModal', () => {
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Places API disabled', 'error', undefined))
})
it('FE-COMP-ADDPLACECOL-009: a search response without places yields no dropdown', async () => {
it('FE-COMP-ADDPLACECOL-009: a search that finds nothing says so instead of staying silent', async () => {
server.use(http.post('/api/maps/search', () => HttpResponse.json({ source: 'nominatim' })))
setup()
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
await waitFor(() => expect(screen.getByRole('button', { name: /Search/ })).not.toBeDisabled())
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
expect(await screen.findByText('No places found')).toBeInTheDocument()
// Typing the next query clears it again.
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissaten' } })
expect(screen.queryByText('No places found')).not.toBeInTheDocument()
})
it('FE-COMP-ADDPLACECOL-009b: the empty search panel can be dismissed', async () => {
server.use(http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'nominatim' })))
setup()
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
await screen.findByText('No places found')
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByText('No places found')).not.toBeInTheDocument()
})
it('FE-COMP-ADDPLACECOL-010: saving posts the manually typed place with nulls for what is empty', async () => {
@@ -360,4 +374,49 @@ describe('AddPlaceToCollectionModal', () => {
expect(props.onClose).toHaveBeenCalledTimes(1)
expect(savedBodies).toHaveLength(0)
})
it('FE-COMP-ADDPLACECOL-025: the caret goes back to the search field after an add', async () => {
setup()
const nameField = screen.getByPlaceholderText('Name')
typeName('Manual spot')
// Where the focus sits in the browser once the add button disables itself.
nameField.focus()
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(nameField).toHaveValue(''))
expect(screen.getByPlaceholderText('Search for a place…')).toHaveFocus()
})
it('FE-COMP-ADDPLACECOL-026: the search still runs after a place was added', async () => {
mockSearch([mapsResult])
setup()
typeName('Manual spot')
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(screen.getByPlaceholderText('Name')).toHaveValue(''))
const input = screen.getByPlaceholderText('Search for a place…')
expect(input).toHaveValue('')
fireEvent.change(input, { target: { value: 'kissa' } })
expect(screen.getByRole('button', { name: /Search/ })).not.toBeDisabled()
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Kissa Sakaiki')).toBeInTheDocument()
})
it('FE-COMP-ADDPLACECOL-027: the search row is pinned to the top of the scrolling body', () => {
setup()
// The dialog stays open after an add, so the row has to survive a scrolled form.
expect(screen.getByPlaceholderText('Search for a place…').closest('.sticky')).not.toBeNull()
})
it('FE-COMP-ADDPLACECOL-028: a failed save leaves the caret where the user had it', async () => {
server.use(http.post('/api/addons/collections/places', () => HttpResponse.json({ error: 'List is full' }, { status: 400 })))
setup()
const nameField = screen.getByPlaceholderText('Name')
typeName('Manual spot')
nameField.focus()
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined))
expect(nameField).toHaveFocus()
})
})
@@ -42,6 +42,9 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
const [query, setQuery] = useState('')
const [results, setResults] = useState<MapsPlace[]>([])
const [searching, setSearching] = useState(false)
// A search that came back empty used to render nothing at all, which reads as
// "the dialog is dead". Say so instead (#1921).
const [noResults, setNoResults] = useState(false)
// The picked location (address/coords/ids) plus the editable fields.
const [picked, setPicked] = useState<MapsPlace | null>(null)
const [name, setName] = useState('')
@@ -56,16 +59,20 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
const [status, setStatus] = useState<CollectionStatus>('idea')
const [saving, setSaving] = useState(false)
const descRef = useRef<HTMLTextAreaElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const reset = () => { setQuery(''); setResults([]); setPicked(null); setName(''); setAddress(''); setLat(''); setLng(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
const reset = () => { setQuery(''); setResults([]); setNoResults(false); setPicked(null); setName(''); setAddress(''); setLat(''); setLng(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
useEffect(() => { if (!isOpen) reset() }, [isOpen])
const search = async () => {
if (!query.trim()) return
setSearching(true)
setNoResults(false)
try {
const res = await mapsApi.search(query, language)
setResults((res.places as MapsPlace[]) || [])
const places = (res.places as MapsPlace[]) || []
setResults(places)
setNoResults(places.length === 0)
} catch (err) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
@@ -73,6 +80,8 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
}
}
const dismissResults = () => { setResults([]); setNoResults(false) }
const pick = (r: MapsPlace) => {
setPicked(r)
setName(str(r.name) ?? '')
@@ -80,7 +89,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
const la = num(r.lat); const lo = num(r.lng)
setLat(la != null ? String(la) : '')
setLng(lo != null ? String(lo) : '')
setResults([]); setQuery(str(r.name) ?? query)
setResults([]); setNoResults(false); setQuery(str(r.name) ?? query)
}
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
@@ -112,6 +121,11 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
if (res.duplicate) toast.info(t('collections.duplicateWarning'))
else { toast.success(t('collections.addedToList', { name: collectionName })); onAdded() }
reset()
// The dialog stays open for the next place, so hand the caret back to the
// search field. The add button the user just clicked goes disabled with the
// cleared name, which drops the focus to <body> and leaves the dialog dead
// to the keyboard (#1921).
searchRef.current?.focus()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
@@ -142,42 +156,51 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
}
>
<div className="flex flex-col gap-4">
{/* Search — picking a result fills the location below */}
<div className="relative">
<div className="flex gap-2">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }}
placeholder={t('collections.addPlaceSearch')}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent"
/>
</div>
<button type="button" onClick={search} disabled={!query.trim() || searching} className="px-4 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-2">
{searching ? <Loader2 size={15} className="animate-spin" /> : <Search size={15} />}
{t('common.search')}
</button>
</div>
{results.length > 0 && (
<div className="absolute z-20 left-0 right-0 mt-1.5 max-h-[280px] overflow-y-auto rounded-xl border border-edge bg-surface-card shadow-lg p-1.5 flex flex-col gap-1">
<div className="flex items-center justify-between px-2 py-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-content-faint">{t('common.search')}</span>
<button type="button" onClick={() => setResults([])} className="p-1 rounded-md text-content-faint hover:text-content hover:bg-surface-hover" aria-label={t('common.close')}><X size={13} /></button>
{/* Search — picking a result fills the location below. Pinned to the top of
the scrolling dialog body: the dialog stays open after an add, and the
search row used to sit above the viewport once the form was scrolled,
so it looked like the button had vanished (#1921). The negative margins
let its background cover the body padding while it is stuck. */}
<div className="sticky top-0 z-20 -mx-6 -mt-6 -mb-4 px-6 pt-6 pb-4 bg-surface-card">
<div className="relative">
<div className="flex gap-2">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
ref={searchRef}
value={query}
onChange={e => { setQuery(e.target.value); setNoResults(false) }}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }}
placeholder={t('collections.addPlaceSearch')}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent"
/>
</div>
{results.map((r, i) => (
<button key={i} type="button" onClick={() => pick(r)} className="flex items-center gap-3 px-2.5 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors">
<div className="w-8 h-8 min-w-[32px] rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={15} /></div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13px] font-semibold text-content truncate">{str(r.name)}</span>
{str(r.address) && <span className="text-[11.5px] text-content-faint truncate">{str(r.address)}</span>}
</div>
</button>
))}
<button type="button" onClick={search} disabled={!query.trim() || searching} className="px-4 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-2">
{searching ? <Loader2 size={15} className="animate-spin" /> : <Search size={15} />}
{t('common.search')}
</button>
</div>
)}
{(results.length > 0 || noResults) && (
<div className="absolute z-20 left-0 right-0 mt-1.5 max-h-[280px] overflow-y-auto rounded-xl border border-edge bg-surface-card shadow-lg p-1.5 flex flex-col gap-1">
<div className="flex items-center justify-between px-2 py-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-content-faint">{t('common.search')}</span>
<button type="button" onClick={dismissResults} className="p-1 rounded-md text-content-faint hover:text-content hover:bg-surface-hover" aria-label={t('common.close')}><X size={13} /></button>
</div>
{noResults ? (
<div className="px-2.5 py-3 text-center text-[12.5px] text-content-faint">{t('planner.noPlacesFound')}</div>
) : results.map((r, i) => (
<button key={i} type="button" onClick={() => pick(r)} className="flex items-center gap-3 px-2.5 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors">
<div className="w-8 h-8 min-w-[32px] rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={15} /></div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13px] font-semibold text-content truncate">{str(r.name)}</span>
{str(r.address) && <span className="text-[11.5px] text-content-faint truncate">{str(r.address)}</span>}
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Name */}
@@ -1,4 +1,4 @@
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-035
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-043
import React from 'react';
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -335,6 +335,8 @@ describe('CollectionPlaceDetail', () => {
links: [{ label: undefined, url: 'https://x.com' }],
category_id: 2,
label_ids: [1, 2],
// Untouched in this case, but the address rides along on every save (#1870).
address: 'Somewhere',
lat: 52.5,
lng: 13.4,
});
@@ -547,3 +549,56 @@ describe('CollectionPlaceDetail — open in maps', () => {
expect(screen.queryByRole('button', { name: /navigation|google maps/i })).toBeNull();
});
});
// ── Editable address (#1870) ─────────────────────────────────────────────────
describe('CollectionPlaceDetail: editable address', () => {
it('FE-COMP-COLDETAIL-040: edit mode offers an address field seeded from the place', async () => {
const user = userEvent.setup();
renderDetail({ canEdit: true });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
expect(screen.getByText('Address')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('Somewhere');
});
it('FE-COMP-COLDETAIL-041: a corrected address is trimmed into the patch', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
const addressInput = screen.getByPlaceholderText('Street, City, Country');
await user.clear(addressInput);
await user.type(addressInput, ' Via Nuova 1 ');
await user.click(screen.getByRole('button', { name: /Save/ }));
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ address: 'Via Nuova 1' }));
});
it('FE-COMP-COLDETAIL-042: an emptied address is sent as null', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
await user.clear(screen.getByPlaceholderText('Street, City, Country'));
await user.click(screen.getByRole('button', { name: /Save/ }));
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ address: null }));
});
it('FE-COMP-COLDETAIL-043: Cancel restores the stored address', async () => {
const user = userEvent.setup();
const props = renderDetail({ canEdit: true });
await user.click(await screen.findByRole('button', { name: 'Edit' }));
await user.type(screen.getByPlaceholderText('Street, City, Country'), ' 2');
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(props.onSave).not.toHaveBeenCalled();
expect(screen.getByText('Somewhere')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Edit' }));
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('Somewhere');
});
});
@@ -35,7 +35,7 @@ interface CollectionPlaceDetailProps {
anchorRect?: { left: number; width: number } | null
onClose: () => void
onSetStatus: (status: CollectionStatus) => void
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; lat?: number | null; lng?: number | null }) => Promise<void>
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; lat?: number | null; lng?: number | null; address?: string | null }) => Promise<void>
/** Upload a custom cover image (#1136); enables the cover change/remove controls. */
onUploadImage?: (file: File) => Promise<void>
onCopyToTrip: () => void
@@ -83,6 +83,7 @@ export default function CollectionPlaceDetail({
const [description, setDescription] = useState(place.description ?? '')
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
const [labelIds, setLabelIds] = useState<number[]>(place.label_ids ?? [])
const [address, setAddress] = useState(place.address ?? '')
const [lat, setLat] = useState(place.lat != null ? String(place.lat) : '')
const [lng, setLng] = useState(place.lng != null ? String(place.lng) : '')
const [saving, setSaving] = useState(false)
@@ -99,6 +100,7 @@ export default function CollectionPlaceDetail({
setDescription(place.description ?? '')
setLinks(place.links ?? [])
setLabelIds(place.label_ids ?? [])
setAddress(place.address ?? '')
setLat(place.lat != null ? String(place.lat) : '')
setLng(place.lng != null ? String(place.lng) : '')
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -147,7 +149,7 @@ export default function CollectionPlaceDetail({
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id])
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') }
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setAddress(place.address ?? ''); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') }
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData('text').trim()
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
@@ -161,7 +163,7 @@ export default function CollectionPlaceDetail({
const lngNum = lng.trim() ? Number(lng) : NaN
setSaving(true)
try {
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null })
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, address: address.trim() || null, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null })
setEditing(false)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
@@ -280,6 +282,11 @@ export default function CollectionPlaceDetail({
})}
</div>
</div>
{/* Address (#1870): free text, the coordinates stay separate */}
<div className="col-detail-field">
<div className="col-detail-label"><MapPin size={12} /> {t('places.formAddress')}</div>
<input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className="col-detail-input" />
</div>
{/* Coordinates */}
<div className="col-detail-field">
<div className="col-detail-label"><MapPin size={12} /> {t('collections.coordinates')}</div>
@@ -115,7 +115,7 @@ describe('MSaveToCollectionSheet', () => {
it('FE-COMP-MSAVESHEET-006: lists already holding the place are marked saved', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
saved: true,
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
});
openFor();
render(<MSaveToCollectionSheet />);
@@ -167,7 +167,7 @@ describe('MSaveToCollectionSheet', () => {
it('FE-COMP-MSAVESHEET-010: tapping a saved list removes that place instead', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
saved: true,
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
});
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
openFor();
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, useCallback } from 'react'
import { useNavigate } from 'react-router'
import { Bookmark, BookmarkCheck, Check, Loader2, Plus, X } from 'lucide-react'
import { Bookmark, BookmarkCheck, Check, CheckCircle2, Loader2, Plus, X } from 'lucide-react'
import MSheet from '../../mobile/components/MSheet'
import MIconBtn from '../../mobile/components/MIconBtn'
import { useToast } from '../shared/Toast'
@@ -8,7 +8,8 @@ import { useTranslation } from '../../i18n'
import { collectionsApi } from '../../api/collections'
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { getApiErrorMessage } from '../../utils/apiError'
import type { Collection, CollectionMembership } from '@trek/shared'
import { STATUS_META, nextStatus } from '../../pages/collections/collectionsModel'
import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared'
/**
* Mobile counterpart of SaveToCollectionModal — the same store-driven list
@@ -71,12 +72,44 @@ export default function MSaveToCollectionSheet() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target])
const savedByCollection = new Map<number, number>()
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id)
const savedByCollection = new Map<number, CollectionMembership['lists'][number]>()
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l)
/** Lists holding this place that the viewer may edit and that are not visited yet. */
const unvisited = (membership?.lists ?? []).filter(l => l.can_edit && l.status !== 'visited')
const handleStatus = async (entry: CollectionMembership['lists'][number], next: CollectionStatus) => {
if (busyId != null) return
setBusyId(entry.collection_id)
try {
await collectionsApi.setStatus(entry.place_id, next)
await refreshMembership()
bumpVersion()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusyId(null)
}
}
const handleVisitedEverywhere = async () => {
if (busyId != null || unvisited.length === 0) return
setBusyId(-1)
try {
const { updated } = await collectionsApi.setStatusMany(unvisited.map(l => l.place_id), 'visited')
toast.success(t('collections.markedVisited', { count: updated }))
await refreshMembership()
bumpVersion()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusyId(null)
}
}
const handleToggle = async (list: Collection) => {
if (busyId != null || !target) return
const savedPlaceId = savedByCollection.get(list.id)
const savedPlaceId = savedByCollection.get(list.id)?.place_id
setBusyId(list.id)
try {
if (savedPlaceId != null) {
@@ -156,8 +189,11 @@ export default function MSaveToCollectionSheet() {
</div>
) : (
lists.map(list => {
const saved = savedByCollection.has(list.id)
const entry = savedByCollection.get(list.id)
const saved = !!entry
const busy = busyId === list.id
const statusMeta = entry ? STATUS_META[entry.status] : null
const StatusIcon = statusMeta?.icon
return (
<button
key={list.id}
@@ -178,9 +214,27 @@ export default function MSaveToCollectionSheet() {
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{list.name}</span>
<span className="mt-px block font-geist text-[0.65625rem] text-m-muted">
{t('collections.placeCount', { count: list.place_count ?? 0 })}
</span>
{/* Saved lists show their own status here instead of the place
count — that number matters when picking a list, the status
matters once the place is in it (#1469). */}
{entry && statusMeta && StatusIcon ? (
<span
role={entry.can_edit ? 'button' : undefined}
tabIndex={entry.can_edit ? 0 : undefined}
aria-label={t(statusMeta.labelKey)}
onClick={entry.can_edit ? e => { e.preventDefault(); e.stopPropagation(); void handleStatus(entry, nextStatus(entry.status)) } : undefined}
onKeyDown={entry.can_edit ? e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); void handleStatus(entry, nextStatus(entry.status)) } } : undefined}
className="mt-[3px] inline-flex items-center gap-1 rounded-full bg-[color:var(--m-inner)] px-2 py-[3px] font-geist text-[0.65625rem] font-semibold"
style={{ color: statusMeta.color }}
>
<StatusIcon size={11} strokeWidth={2.4} />
{t(statusMeta.labelKey)}
</span>
) : (
<span className="mt-px block font-geist text-[0.65625rem] text-m-muted">
{t('collections.placeCount', { count: list.place_count ?? 0 })}
</span>
)}
</span>
{list.is_owner === false && (
<span className="flex-none font-geist text-[0.5625rem] font-bold uppercase tracking-[.05em] text-m-faint">
@@ -200,6 +254,22 @@ export default function MSaveToCollectionSheet() {
)}
</div>
{unvisited.length > 0 && (
<div className="flex-none px-[14px] pb-1">
<button
type="button"
onClick={handleVisitedEverywhere}
disabled={busyId != null}
className="flex w-full items-center justify-center gap-1.5 rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[10px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-60"
>
{busyId === -1
? <Loader2 size={14} className="animate-spin" />
: <CheckCircle2 size={14} strokeWidth={2.2} />}
{unvisited.length > 1 ? t('collections.markVisitedAll') : t('collections.markVisited')}
</button>
</div>
)}
{lists.length > 0 && (
<div className="flex flex-none items-center justify-between gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] py-3">
<button
@@ -1,4 +1,4 @@
// FE-COMP-SAVETOCOL-001 to FE-COMP-SAVETOCOL-016
// FE-COMP-SAVETOCOL-001 to FE-COMP-SAVETOCOL-020
import React from 'react';
import { afterEach, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
@@ -120,7 +120,7 @@ describe('SaveToCollectionModal', () => {
it('FE-COMP-SAVETOCOL-006: lists already holding the place are marked saved', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
saved: true,
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
});
openFor();
render(<SaveToCollectionModal />);
@@ -194,7 +194,7 @@ describe('SaveToCollectionModal', () => {
it('FE-COMP-SAVETOCOL-011: picking a saved list removes that place instead', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
saved: true,
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
});
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
const save = vi.spyOn(collectionsApi, 'savePlace');
@@ -263,6 +263,65 @@ describe('SaveToCollectionModal', () => {
expect(screen.queryByText('Favorites')).not.toBeInTheDocument();
});
// ── Per-list status + "visited everywhere" (#1469) ────────────────────────
const savedIn = (over: Partial<CollectionMembership['lists'][number]> = {}): CollectionMembership => ({
saved: true,
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true, ...over }],
});
it('FE-COMP-SAVETOCOL-017: a saved list shows its own status and one tap cycles it', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(savedIn());
const setStatus = vi.spyOn(collectionsApi, 'setStatus').mockResolvedValue({} as never);
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
openFor();
render(<SaveToCollectionModal />);
const badge = await screen.findByRole('button', { name: 'Want to go' });
fireEvent.click(badge);
// want → visited, and the row toggle must NOT have fired underneath it.
await waitFor(() => expect(setStatus).toHaveBeenCalledWith(900, 'visited'));
expect(del).not.toHaveBeenCalled();
});
it('FE-COMP-SAVETOCOL-018: the header action marks every list holding the place visited', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
saved: true,
lists: [
{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true },
{ collection_id: 2, name: 'Wishlist', place_id: 901, status: 'idea', can_edit: true },
],
});
const many = vi.spyOn(collectionsApi, 'setStatusMany').mockResolvedValue({ updated: 2 });
openFor();
render(<SaveToCollectionModal />);
fireEvent.click(await screen.findByRole('button', { name: 'Visited everywhere' }));
await waitFor(() => expect(many).toHaveBeenCalledWith([900, 901], 'visited'));
});
it('FE-COMP-SAVETOCOL-019: the action disappears once every list is visited', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(savedIn({ status: 'visited' }));
openFor();
render(<SaveToCollectionModal />);
await screen.findByText('Favorites');
expect(screen.queryByRole('button', { name: /Visited everywhere|Mark visited/ })).not.toBeInTheDocument();
});
it('FE-COMP-SAVETOCOL-020: a read-only list shows its status but does not offer to change it', async () => {
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(savedIn({ can_edit: false }));
openFor();
render(<SaveToCollectionModal />);
await screen.findByText('Favorites');
expect(screen.queryByRole('button', { name: 'Want to go' })).not.toBeInTheDocument();
expect(screen.getByTitle('Want to go')).toBeInTheDocument();
// Nothing to offer: the only list holding it is one the viewer cannot edit.
expect(screen.queryByRole('button', { name: /Visited everywhere|Mark visited/ })).not.toBeInTheDocument();
});
it('FE-COMP-SAVETOCOL-014: the footer navigates to the collections page or just closes', async () => {
openFor();
const { unmount } = render(<SaveToCollectionModal />);
@@ -1,13 +1,14 @@
import React, { useEffect, useMemo, useState, useCallback } from 'react'
import { useNavigate } from 'react-router'
import { Bookmark, BookmarkCheck, Check, Loader2, Plus } from 'lucide-react'
import { Bookmark, BookmarkCheck, Check, CheckCircle2, Loader2, Plus } from 'lucide-react'
import Modal from '../shared/Modal'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { collectionsApi } from '../../api/collections'
import StatusBadge from './StatusBadge'
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { getApiErrorMessage } from '../../utils/apiError'
import type { Collection, CollectionMembership } from '@trek/shared'
import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared'
/**
* Globally-mounted list picker for the "Save to Collection" entry points
@@ -70,12 +71,44 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
if (!target) return null
const savedByCollection = new Map<number, number>()
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id)
const savedByCollection = new Map<number, CollectionMembership['lists'][number]>()
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l)
/** Lists holding this place that the viewer may edit and that are not visited yet. */
const unvisited = (membership?.lists ?? []).filter(l => l.can_edit && l.status !== 'visited')
const handleStatus = async (entry: CollectionMembership['lists'][number], next: CollectionStatus) => {
if (busyId != null) return
setBusyId(entry.collection_id)
try {
await collectionsApi.setStatus(entry.place_id, next)
await refreshMembership()
bumpVersion()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusyId(null)
}
}
const handleVisitedEverywhere = async () => {
if (busyId != null || unvisited.length === 0) return
setBusyId(-1)
try {
const { updated } = await collectionsApi.setStatusMany(unvisited.map(l => l.place_id), 'visited')
toast.success(t('collections.markedVisited', { count: updated }))
await refreshMembership()
bumpVersion()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusyId(null)
}
}
const handleToggle = async (list: Collection) => {
if (busyId != null) return
const savedPlaceId = savedByCollection.get(list.id)
const savedPlaceId = savedByCollection.get(list.id)?.place_id
setBusyId(list.id)
try {
if (savedPlaceId != null) {
@@ -140,7 +173,23 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
}
>
<div className="flex flex-col gap-2">
<p className="text-[13px] font-semibold text-content truncate">{target.name}</p>
<div className="flex items-center gap-2 min-w-0">
<p className="flex-1 min-w-0 text-[13px] font-semibold text-content truncate">{target.name}</p>
{/* One tap for "I have been here", however many lists hold it (#1469). */}
{unvisited.length > 0 && (
<button
type="button"
onClick={handleVisitedEverywhere}
disabled={busyId != null}
className="shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-edge bg-surface-card text-[11px] font-semibold text-content-secondary hover:bg-surface-hover disabled:opacity-60"
>
{busyId === -1
? <Loader2 size={12} className="animate-spin" />
: <CheckCircle2 size={12} strokeWidth={2.2} />}
{unvisited.length > 1 ? t('collections.markVisitedAll') : t('collections.markVisited')}
</button>
)}
</div>
{loading ? (
<div className="flex items-center justify-center py-8 text-content-faint">
<Loader2 size={20} className="animate-spin" />
@@ -162,7 +211,8 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
) : (
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
{lists.map(list => {
const saved = savedByCollection.has(list.id)
const entry = savedByCollection.get(list.id)
const saved = !!entry
const busy = busyId === list.id
return (
<button
@@ -170,13 +220,24 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
type="button"
onClick={() => handleToggle(list)}
disabled={busyId != null}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
>
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: list.color || 'var(--accent)' }} />
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{list.name}</span>
{list.is_owner === false && (
<span className="text-[10px] uppercase font-semibold text-content-faint">{t('collections.shared')}</span>
)}
{/* Per-list status, so one place can be an idea in one list and
visited in another. A role=button span, safe to nest here. */}
{entry && (
<StatusBadge
status={entry.status}
showLabel={false}
size={12}
onChange={entry.can_edit ? next => { void handleStatus(entry, next) } : undefined}
t={t}
/>
)}
<span className={`w-5 h-5 rounded-md flex items-center justify-center shrink-0 ${saved ? 'bg-accent text-accent-text' : 'border border-edge text-transparent'}`}>
{busy ? <Loader2 size={13} className="animate-spin text-content-faint" /> : saved ? <BookmarkCheck size={13} /> : <Check size={13} />}
</span>
+4 -1
View File
@@ -899,7 +899,10 @@ export const MapView = memo(function MapView({
onClick={cycleTrackingMode}
bottomOffset={locationButtonBottom as unknown as number}
/>}
<div style={{ position: 'absolute', left: leftWidth + 12, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
smaller gap its shadow ran into the sidebar edge and the two read as one
surface. */}
<div style={{ position: 'absolute', left: leftWidth + 20, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
</div>
</div>
@@ -1,10 +1,11 @@
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-012
//
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
// that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern).
// Tests verify the overlay DOM structure and HTML content.
// JourneyBookPDF.tsx exports an async function
// `downloadJourneyBookPDF(journey, { t, locale })` that renders a PDF preview in
// an srcdoc iframe overlay (Safari-safe pattern). Tests verify the overlay DOM
// structure, the HTML content and that the book follows the app language.
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
// Mock `marked` so we don't need the real markdown parser
vi.mock('marked', () => ({
@@ -87,6 +88,18 @@ function getIframe(): HTMLIFrameElement | null {
return getOverlay()?.querySelector('iframe') ?? null;
}
// Echoes the key (plus any params) so the assertions read as "this string comes
// from i18n" instead of pinning English copy.
function t(key: string, params?: Record<string, string | number>): string {
if (!params) return key;
return `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})`;
}
const longDate = (d: string, locale: string) =>
new Date(d + 'T00:00:00').toLocaleDateString(locale, {
weekday: 'long', month: 'long', day: 'numeric', year: 'numeric',
});
// ── Setup ────────────────────────────────────────────────────────────────────
afterEach(() => {
@@ -142,9 +155,10 @@ describe('downloadJourneyBookPDF', () => {
expect(getOverlay()).not.toBeNull();
const html = getIframe()!.srcdoc;
expect(html).toContain('Iceland Ring Road');
// No entry pages, but cover and closing page are still present
expect(html).toContain('Journey Book');
expect(html).toContain('The End');
// No entry pages, but cover and closing page are still present. Without a
// translator the chrome falls back to the raw keys instead of throwing.
expect(html).toContain('journey.pdf.journeyBook');
expect(html).toContain('journey.pdf.theEnd');
});
it('FE-COMP-JOURNEYPDF-007: sanitises HTML injected via an entry story and keeps the iframe script-free', async () => {
@@ -162,4 +176,72 @@ describe('downloadJourneyBookPDF', () => {
expect(html).toContain('Hello');
expect(html).toContain('world');
});
it('FE-COMP-JOURNEYPDF-008: renders cover, day header and closing page through i18n', async () => {
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'de-DE' });
const html = getIframe()!.srcdoc;
expect(html).toContain('<html lang="de">');
expect(html).toContain('journey.pdf.journeyBook');
expect(html).toContain('journey.stats.days');
expect(html).toContain('journey.stats.entries');
expect(html).toContain('journey.stats.photos');
expect(html).toContain('journey.pdf.madeWith');
expect(html).toContain('journey.pdf.theEnd');
// Day header is the parameterised key plus a date in the picked locale —
// the hardcoded 'en' formatting would not produce this string.
expect(html).toContain('journey.detail.day(number=1)');
expect(html).toContain(longDate('2026-07-01', 'de-DE'));
// Pros/cons headings too.
expect(html).toContain('journey.verdict.lovedIt');
expect(html).toContain('journey.verdict.couldBeBetter');
});
it('FE-COMP-JOURNEYPDF-009: translates the preview bar above the book', async () => {
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'de-DE' });
const overlay = getOverlay()!;
// cover + one entry page + closing page
expect(overlay.textContent).toContain('3 journey.pdf.pages');
expect(overlay.querySelector('#journey-pdf-save')!.textContent).toBe('journey.pdf.saveAsPdf');
expect(overlay.querySelector('#journey-pdf-close')!.textContent).toBe('common.close');
});
it('FE-COMP-JOURNEYPDF-010: prints mood and weather next to the entry', async () => {
const journey = buildJourney();
journey.entries[0].mood = 'good';
journey.entries[0].weather = 'rainy';
await downloadJourneyBookPDF(journey, { t, locale: 'en-US' });
const html = getIframe()!.srcdoc;
expect(html).toContain('journey.mood.good');
expect(html).toContain('journey.weather.rainy');
// Icons come along as inline SVG inside the chips.
expect(html).toContain('class="entry-chips"');
expect(html).toContain('<svg');
});
it('FE-COMP-JOURNEYPDF-011: skips chips for values the app does not know', async () => {
// The fixture carries mood 'excited', which is not one of the four moods.
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'en-US' });
const html = getIframe()!.srcdoc;
// Scoped to the chip row: the page elsewhere is free to carry whatever the
// fixture happens to leave undefined, and that is not what this asserts.
const chips = html.match(/<div class="entry-chips">[\s\S]*?<\/div>/)?.[0] ?? '';
expect(chips).not.toBe('');
expect(chips).not.toContain('undefined');
expect(chips).not.toContain('journey.mood.');
// The known weather value still gets its chip.
expect(chips).toContain('journey.weather.sunny');
});
it('FE-COMP-JOURNEYPDF-012: leaves out the chip row when an entry has neither', async () => {
const journey = buildJourney();
journey.entries[0].mood = null;
journey.entries[0].weather = null;
await downloadJourneyBookPDF(journey, { t, locale: 'en-US' });
expect(getIframe()!.srcdoc).not.toContain('class="entry-chips"');
});
});
+92 -27
View File
@@ -1,7 +1,13 @@
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
import { createElement } from 'react'
import type { LucideIcon } from 'lucide-react'
import { marked } from 'marked'
import { sanitizeRichTextHtml } from '@trek/shared'
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { renderIconMarkup } from '../../utils/iconMarkup'
type Translate = (key: string, params?: Record<string, string | number>) => string
function esc(str: string | null | undefined): string {
if (!str) return ''
@@ -25,13 +31,9 @@ function pSrc(p: JourneyPhoto): string {
return abs(`/api/photos/${p.photo_id}/original`)
}
function fmtDate(d: string): string {
function fmtDate(d: string, locale: string): string {
const date = new Date(d + 'T00:00:00')
return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
}
function fmtShort(d: string): string {
return new Date(d + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' })
return date.toLocaleDateString(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
}
function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
@@ -44,7 +46,7 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
return groups
}
function renderProscons(entry: JourneyEntry): string {
function renderProscons(entry: JourneyEntry, tr: Translate): string {
const pc = entry.pros_cons
if (!pc) return ''
const pros = pc.pros?.filter(p => p.trim()) || []
@@ -52,11 +54,31 @@ function renderProscons(entry: JourneyEntry): string {
if (pros.length === 0 && cons.length === 0) return ''
return `<div class="verdict-wrap"><div class="verdict-row">
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">Loved it</div><ul>${pros.map(p => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">Could be better</div><ul>${cons.map(c => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">${esc(tr('journey.verdict.lovedIt'))}</div><ul>${pros.map(p => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">${esc(tr('journey.verdict.couldBeBetter'))}</div><ul>${cons.map(c => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
</div></div>`
}
function chip(icon: LucideIcon, label: string, bg: string, color: string): string {
const svg = renderIconMarkup(createElement(icon, { size: 12, strokeWidth: 2, color }))
return `<span class="chip" style="background:${bg};color:${color}">${svg}${esc(label)}</span>`
}
/**
* Mood and weather were on screen but never in the book (#1848). Unknown values
* (older entries, a provider-set category we don't render) drop out silently,
* the same way MoodChip/WeatherChip return null for them.
*/
function renderMoodWeather(entry: JourneyEntry, tr: Translate): string {
const chips: string[] = []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : undefined
if (mood) chips.push(chip(mood.icon, tr(mood.label), mood.bg, mood.text))
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : undefined
if (weather) chips.push(chip(weather.icon, tr(weather.label), '#f4f4f5', '#3f3f46'))
if (chips.length === 0) return ''
return `<div class="entry-chips">${chips.join('')}</div>`
}
function renderPhotoBlock(photos: JourneyPhoto[]): string {
if (photos.length === 0) return ''
if (photos.length === 1) {
@@ -75,7 +97,16 @@ function renderPhotoBlock(photos: JourneyPhoto[]): string {
</div>`
}
export async function downloadJourneyBookPDF(journey: JourneyDetail) {
/**
* `opts` stays optional so the signature is additive, but every caller passes
* the page's `t`/`locale` — without them the book falls back to raw keys (#1848).
*/
export async function downloadJourneyBookPDF(
journey: JourneyDetail,
opts: { t?: Translate; locale?: string } = {},
) {
const tr: Translate = opts.t || (key => key)
const loc = opts.locale || 'en'
const entries = (journey.entries || []).filter(e => e.type !== 'skeleton')
const allPhotos = entries.flatMap(e => e.photos || [])
const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '')
@@ -96,14 +127,14 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
// Day header (inline, only on first entry of day)
const dayHeaderHtml = isFirstOfDay
? `<div class="day-header">Day ${di + 1} · ${fmtDate(date)}</div>`
? `<div class="day-header">${esc(tr('journey.detail.day', { number: di + 1 }))} · ${esc(fmtDate(date, loc))}</div>`
: ''
// Photo block
const photoHtml = renderPhotoBlock(photos)
// Pros/cons
const prosconsHtml = renderProscons(entry)
const prosconsHtml = renderProscons(entry, tr)
// Story (markdown)
const storyHtml = entry.story ? `<div class="entry-story">${md(entry.story)}</div>` : ''
@@ -114,6 +145,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
${photoHtml}
<div class="entry-content">
${meta ? `<div class="entry-meta">${esc(meta)}</div>` : ''}
${renderMoodWeather(entry, tr)}
${entry.title ? `<h2 class="entry-title">${esc(entry.title)}</h2>` : ''}
${storyHtml}
${prosconsHtml}
@@ -126,11 +158,17 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
const totalPages = pageNum + 1 // +1 for closing page
const html = `<!DOCTYPE html>
<html>
<html lang="${esc(loc.split('-')[0])}">
<head>
<meta charset="UTF-8">
<!-- The page is A4 landscape and laid out in pt, so it is about 1123px wide. Without
this, a phone lays the preview out at its default 980px and the document spills
out of the frame, which reads as being zoomed far in. Declaring the real width
makes the browser scale the whole page to fit instead. Printing is unaffected,
it goes by @page. -->
<meta name="viewport" content="width=1123">
<base href="${window.location.origin}/">
<title>${esc(journey.title)} — Journey Book</title>
<title>${esc(journey.title)} — ${esc(tr('journey.pdf.journeyBook'))}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -207,6 +245,9 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
/* Entry content */
.entry-content { flex: 1; }
.entry-meta { font-size: 10pt; letter-spacing: 0.04em; text-transform: uppercase; color: #71717a; font-weight: 500; margin-bottom: 6pt; }
.entry-chips { display: flex; flex-wrap: wrap; gap: 5pt; margin-bottom: 8pt; }
.chip { display: inline-flex; align-items: center; gap: 3pt; padding: 2.5pt 7pt; border-radius: 999pt; font-size: 8.5pt; font-weight: 600; }
.chip svg { width: 9pt; height: 9pt; }
h2.entry-title { font-size: 28pt; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; margin: 0 0 10pt; color: #0a0a0f; }
.entry-story { font-size: 11pt; line-height: 1.65; color: #3f3f46; }
.entry-story p { margin: 0 0 8pt; }
@@ -262,16 +303,16 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
<div class="cover-dim"></div>
<div class="cover-mesh"></div>
<div class="cover-content">
<div class="cover-label">Journey Book</div>
<div class="cover-label">${esc(tr('journey.pdf.journeyBook'))}</div>
<h1>${esc(journey.title)}</h1>
${journey.subtitle ? `<div class="sub">${esc(journey.subtitle)}</div>` : ''}
<div class="cover-stats">
<div><div class="cover-stat-val">${dates.length}</div><div class="cover-stat-label">Days</div></div>
<div><div class="cover-stat-val">${entries.length}</div><div class="cover-stat-label">Entries</div></div>
<div><div class="cover-stat-val">${allPhotos.length}</div><div class="cover-stat-label">Photos</div></div>
<div><div class="cover-stat-val">${dates.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.days'))}</div></div>
<div><div class="cover-stat-val">${entries.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.entries'))}</div></div>
<div><div class="cover-stat-val">${allPhotos.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.photos'))}</div></div>
</div>
</div>
<div class="cover-footer">Made with TREK</div>
<div class="cover-footer">${esc(tr('journey.pdf.madeWith'))}</div>
</div>
<!-- Entry Pages -->
@@ -280,8 +321,8 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
<!-- Closing Page -->
<div class="closing-page">
<div>
<div class="closing-title">The End</div>
<div class="closing-sub">Made with TREK · ${new Date().getFullYear()}</div>
<div class="closing-title">${esc(tr('journey.pdf.theEnd'))}</div>
<div class="closing-sub">${esc(tr('journey.pdf.madeWith'))} · ${new Date().getFullYear()}</div>
</div>
</div>
@@ -296,16 +337,39 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
// The chrome needs a stylesheet rather than inline styles: on a phone the title
// and the two labels do not fit on one row, and translated labels are longer
// than the English ones. Below 640px the card goes full-bleed, the decorative
// title steps aside and the two actions share the width (#1848).
const style = document.createElement('style')
style.textContent = `
#journey-pdf-overlay .jp-card { width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35); }
#journey-pdf-overlay .jp-head { display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a; }
#journey-pdf-overlay .jp-title { min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em; }
#journey-pdf-overlay .jp-actions { display:flex;align-items:center;gap:8px;flex:none; }
#journey-pdf-overlay .jp-btn { min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap; }
#journey-pdf-overlay .jp-save { border:none;background:#fff;color:#0f172a; }
#journey-pdf-overlay .jp-close { border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7); }
@media (max-width: 640px) {
#journey-pdf-overlay { padding:0; }
#journey-pdf-overlay .jp-card { height:100dvh;max-width:none;border-radius:0; }
#journey-pdf-overlay .jp-head { padding:8px 10px; }
#journey-pdf-overlay .jp-title { display:none; }
#journey-pdf-overlay .jp-actions { flex:1; }
#journey-pdf-overlay .jp-btn { flex:1;padding:10px 12px;font-size:13px; }
}
`
const card = document.createElement('div')
card.style.cssText = 'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);'
card.className = 'jp-card'
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;'
header.className = 'jp-head'
header.innerHTML = `
<span style="font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em">${esc(journey.title)} &middot; ${totalPages} pages</span>
<div style="display:flex;align-items:center;gap:8px">
<button id="journey-pdf-save" style="min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:none;background:#fff;color:#0f172a;">Save as PDF</button>
<button id="journey-pdf-close" style="min-height:44px;padding:10px 16px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7);">Close</button>
<span class="jp-title">${esc(journey.title)} &middot; ${totalPages} ${esc(tr('journey.pdf.pages'))}</span>
<div class="jp-actions">
<button id="journey-pdf-save" class="jp-btn jp-save">${esc(tr('journey.pdf.saveAsPdf'))}</button>
<button id="journey-pdf-close" class="jp-btn jp-close">${esc(tr('common.close'))}</button>
</div>
`
@@ -318,6 +382,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(style)
overlay.appendChild(card)
document.body.appendChild(overlay)
@@ -7,13 +7,21 @@ import { catMeta } from '../Budget/costsCategories'
import type { BudgetItem } from '../../types'
/**
* The Costs block inside a booking modal. Replaces the old inline price + budget
* category fields: when no expense is linked yet it offers a "create expense"
* button (the modal saves the booking first, then opens the full Costs editor);
* once linked it shows the expense with edit / remove actions.
* The Costs block inside a booking modal — and, since #1298, inside the place
* form, which links its expense the same way. Replaces the old inline price +
* budget category fields: when no expense is linked yet it offers a "create
* expense" button (the modal saves its own record first, then opens the full
* Costs editor); once linked it shows the expense with edit / remove actions.
*
* Exactly one of reservationId / placeId is set — they are the two sides of the
* same link, and the block behaves identically on both.
*/
export function BookingCostsSection({ reservationId, pendingExpense, onCreate, onEdit, onRemove }: {
export function BookingCostsSection({ reservationId, placeId = null, hintKey = 'reservations.createExpenseHint', pendingExpense, onCreate, onEdit, onRemove }: {
reservationId: number | null
/** Set instead of reservationId when the block sits in the place form (#1298). */
placeId?: number | null
/** What gets saved before the editor opens — "the booking" or "the place". */
hintKey?: string
/** A cost parsed from an import that will be linked on save — previewed before the booking exists. */
pendingExpense?: { total_price: number; currency?: string | null; category: string } | null
onCreate: () => void
@@ -25,7 +33,11 @@ export function BookingCostsSection({ reservationId, pendingExpense, onCreate, o
const trip = useTripStore(s => s.trip)
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null
const linked = reservationId
? budgetItems.find(i => i.reservation_id === reservationId)
: placeId
? budgetItems.find(i => i.place_id === placeId)
: null
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
@@ -40,7 +52,7 @@ export function BookingCostsSection({ reservationId, pendingExpense, onCreate, o
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t(meta.labelKey)}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('reservations.createExpenseHint')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(hintKey)}</div>
</div>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)}</span>
</div>
@@ -76,7 +88,7 @@ export function BookingCostsSection({ reservationId, pendingExpense, onCreate, o
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={15} /> {t('reservations.createExpense')}
</button>
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 6 }}>{t('reservations.createExpenseHint')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 6 }}>{t(hintKey)}</div>
</div>
)
}
@@ -1,11 +1,12 @@
import type { BudgetItem } from '../../types'
/**
* A request from a booking modal to open the Costs expense editor — either to
* edit the already-linked expense, or to create a new one prefilled from the
* booking (the modal saves the booking first so `reservationId` is known).
* A request from a booking modal — or from the place form (#1298) — to open the
* Costs expense editor: either to edit the already-linked expense, or to create
* a new one prefilled from the record. The modal saves itself first, so the
* `reservationId` / `placeId` it names already exists.
*/
export interface BookingExpenseRequest {
editItem?: BudgetItem
prefill?: { reservationId?: number; name?: string; category?: string; amount?: number }
prefill?: { reservationId?: number; placeId?: number; name?: string; category?: string; amount?: number }
}
@@ -184,11 +184,17 @@ beforeEach(() => {
// otherwise leak into the ones after them.
vi.mocked(calculateRouteWithLegs).mockImplementation(waypoints => Promise.resolve({
coordinates: [], distance: 0, duration: 0,
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
mid: [0, 0] as [number, number], from: [0, 0] as [number, number], to: [0, 0] as [number, number],
distance: 2000, duration: 600, distanceText: '2 km', durationText: '10 min',
drivingText: '10 min', walkingText: '25 min',
})),
// Each leg carries its endpoint coordinates (mirrors the real RouteCalculator), so
// connector-driven features that read seg.from/seg.to see faithful [lat, lng] pairs.
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, (_, i) => {
const a = waypoints[i], b = waypoints[i + 1]
return {
mid: [(a.lat + b.lat) / 2, (a.lng + b.lng) / 2] as [number, number],
from: [a.lat, a.lng] as [number, number], to: [b.lat, b.lng] as [number, number],
distance: 2000, duration: 600, distanceText: '2 km', durationText: '10 min',
drivingText: '10 min', walkingText: '25 min',
}
}),
}))
vi.mocked(generateGoogleMapsUrl).mockReturnValue('https://maps.google.com/...')
vi.mocked(generateCoMapsUrl).mockReturnValue('https://comaps.at/...')
@@ -606,16 +612,17 @@ describe('DayPlanSidebar', () => {
// ── PDF export ──────────────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-025: PDF export button is present', () => {
it('FE-PLANNER-DAYPLAN-025: the export button is present', () => {
render(<DayPlanSidebar {...makeDefaultProps()} />)
expect(screen.getByText('PDF')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-026: clicking PDF button calls downloadTripPDF', async () => {
it('FE-PLANNER-DAYPLAN-026: the PDF row in the export dialog calls downloadTripPDF', async () => {
const user = userEvent.setup()
const { downloadTripPDF } = await import('../PDF/TripPDF')
render(<DayPlanSidebar {...makeDefaultProps()} />)
await user.click(screen.getByText('PDF'))
await user.click(screen.getByRole('button', { name: 'Export' }))
await user.click(await screen.findByText('PDF'))
await waitFor(() => {
expect(downloadTripPDF).toHaveBeenCalled()
})
@@ -896,11 +903,14 @@ describe('DayPlanSidebar', () => {
// Day-title renaming moved to DayDetailPanel (#1065) — covered there.
// ── ICS export button ────────────────────────────────────────────────────
// ── ICS export ───────────────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-043: ICS export button is present', () => {
it('FE-PLANNER-DAYPLAN-043: the calendar export sits in the export dialog', async () => {
const user = userEvent.setup()
render(<DayPlanSidebar {...makeDefaultProps()} />)
expect(screen.getByText('ICS')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(await screen.findByText('Calendar')).toBeInTheDocument()
expect(screen.getByText('Download .ics')).toBeInTheDocument()
})
// ── getMergedItems: transport merged with assignments ──────────────────
@@ -1116,13 +1126,12 @@ describe('DayPlanSidebar', () => {
// ── PDF hover tooltip ─────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-054: hovering PDF button shows tooltip', async () => {
it('FE-PLANNER-DAYPLAN-054: hovering the export button shows its tooltip', async () => {
const user = userEvent.setup()
render(<DayPlanSidebar {...makeDefaultProps()} />)
const pdfBtn = screen.getByText('PDF').closest('button')!
await user.hover(pdfBtn)
await user.hover(screen.getByRole('button', { name: 'Export' }))
await waitFor(() => {
// Tooltip text appears (from t('dayplan.pdfTooltip'))
// Tooltip text appears (from t('dayplan.exportIntro'))
const tooltips = document.querySelectorAll('[style*="pointer-events: none"]')
expect(tooltips.length).toBeGreaterThan(0)
})
@@ -1190,10 +1199,9 @@ describe('DayPlanSidebar', () => {
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
render(<DayPlanSidebar {...makeDefaultProps()} />)
// The ICS button now opens a hover menu (Download / Subscribe) instead of
// downloading on direct click.
await user.hover(screen.getByText('ICS').closest('button')!)
await user.click(await screen.findByText('Download ICS'))
// The three export buttons collapsed into one that opens the export dialog.
await user.click(screen.getByRole('button', { name: 'Export' }))
await user.click(await screen.findByText('Download .ics'))
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
fetchSpy.mockRestore()
createObjURL.mockRestore()
@@ -1827,8 +1835,8 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] },
})} />)
const pdfBtn = screen.getByRole('button', { name: /pdf/i })
await user.click(pdfBtn)
await user.click(screen.getByRole('button', { name: 'Export' }))
await user.click(await screen.findByText('PDF'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledWith(
expect.objectContaining({ dayNotes: expect.arrayContaining([expect.objectContaining({ text: 'PDF Note' })]) })
))
@@ -1912,16 +1920,17 @@ describe('DayPlanSidebar', () => {
}
})
// ── ICS hover tooltip ─────────────────────────────────────────────────────
// ── Export dialog ─────────────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows the download/subscribe menu', async () => {
it('FE-PLANNER-DAYPLAN-090: the export button opens the dialog with every format', async () => {
const user = userEvent.setup()
render(<DayPlanSidebar {...makeDefaultProps()} />)
const icsBtn = screen.getByText('ICS').closest('button')!
await user.hover(icsBtn)
await user.click(screen.getByRole('button', { name: 'Export' }))
await waitFor(() => {
expect(screen.getByText('Download ICS')).toBeInTheDocument()
expect(screen.getByText('PDF')).toBeInTheDocument()
expect(screen.getByText('Download .ics')).toBeInTheDocument()
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
expect(screen.getByText('Whole trip')).toBeInTheDocument()
})
})
@@ -3462,6 +3471,100 @@ describe('DayPlanSidebar', () => {
expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenLastCalledWith(1, 11, null)
})
it('FE-PLANNER-DAYPLAN-171b: a stop-to-stop connector offers public transport pre-filled from the leg', async () => {
const user = userEvent.setup()
const onPlanTransitLeg = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignments = {
'10': [
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', place_time: '10:30', lat: 48.86, lng: 2.34 }) }),
buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'Orsay', lat: 48.87, lng: 2.33 }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true, onPlanTransitLeg })} />)
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
// Origin/destination + this stop's departure time, resolved back from the leg coords.
expect(onPlanTransitLeg).toHaveBeenCalledWith({
dayId: 10,
from: { name: 'Louvre', lat: 48.86, lng: 2.34 },
to: { name: 'Orsay', lat: 48.87, lng: 2.33 },
time: '10:30',
})
})
it('FE-PLANNER-DAYPLAN-171c: the public-transport entry is absent without the onPlanTransitLeg handler', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assignments = {
'10': [
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }),
buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true })} />)
await user.click(await screen.findByTitle('Change travel mode'))
expect(contextMenu().queryByRole('button', { name: 'Public transit' })).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-171d: a hotel bookend connector offers public transport from the hotel', async () => {
const user = userEvent.setup()
const onPlanTransitLeg = vi.fn()
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
]
const accommodations: Accommodation[] = [{
id: 1, trip_id: 1, start_day_id: 10, end_day_id: 12,
place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia',
}]
const assignments = {
'11': [
buildAssignment({ id: 11, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', lat: 48.86, lng: 2.34 }) }),
buildAssignment({ id: 12, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'Orsay', lat: 48.87, lng: 2.33 }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11, routeShown: true, onPlanTransitLeg })} />)
// The morning bookend (hotel -> first stop) is the first connector in the list.
const connectors = await screen.findAllByTitle('Change travel mode')
await user.click(connectors[0])
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({
dayId: 11,
from: { name: 'Hotel Lutetia', lat: 48.85, lng: 2.35 },
to: { name: 'Louvre', lat: 48.86, lng: 2.34 },
// The hotel has no place_time, so the departure time falls back to null (the
// panel then uses its own 09:00 default).
time: null,
}))
})
it('FE-PLANNER-DAYPLAN-171e: a revisited stop seeds THIS day\'s departure time, not another day\'s', async () => {
const user = userEvent.setup()
const onPlanTransitLeg = vi.fn()
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
]
// The SAME located POI (identical coords) is visited on both days at different
// times. Day 10 has it alone (no leg); day 11 pairs it with Rodin (one leg).
const assignments = {
'10': [
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', place_time: '09:00', lat: 48.86, lng: 2.34 }) }),
],
'11': [
buildAssignment({ id: 21, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', place_time: '16:30', lat: 48.86, lng: 2.34 }) }),
buildAssignment({ id: 22, day_id: 11, order_index: 1, place: buildPlace({ id: 3, name: 'Rodin', lat: 48.855, lng: 2.315 }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, selectedDayId: 11, routeShown: true, onPlanTransitLeg })} />)
await user.click(await screen.findByTitle('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
// Day 11's own 16:30, not day 10's 09:00 (a trip-wide coord index would leak it).
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({ dayId: 11, time: '16:30' }))
})
it('FE-PLANNER-DAYPLAN-172: a failing per-segment save is reported and the days are refetched', async () => {
const user = userEvent.setup()
const { assignmentsApi } = await import('../../api/client')
+108 -39
View File
@@ -5,6 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
import { type PickedPlace } from './TransitSearchPanel'
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
@@ -99,6 +100,12 @@ interface DayPlanSidebarProps {
onAddTransport?: (dayId: number) => void
/** Opens the public-transit route search for a day (#1065). */
onPlanTransit?: (dayId: number) => void
/**
* Opens the public-transit search pre-filled for a single leg (#1281 follow-up):
* the leg's origin and destination endpoints plus the origin's departure time,
* so "public transport" becomes an option on any connector, not just the day header.
*/
onPlanTransitLeg?: (leg: { dayId: number; from: PickedPlace; to: PickedPlace; time: string | null }) => void
/** Opens the journey view for a saved transit entry (#1065). */
onOpenTransit?: (reservation: Reservation) => void
onEditTransport?: (reservation: Reservation) => void
@@ -148,6 +155,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onRouteRefresh,
onAddTransport,
onPlanTransit,
onPlanTransitLeg,
onOpenTransit,
onEditTransport,
onEditReservation,
@@ -203,7 +211,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [lockedIds, setLockedIds] = useState(new Set())
const [lockHoverId, setLockHoverId] = useState(null)
const [undoHover, setUndoHover] = useState(false)
const [icsHover, setIcsHover] = useState(false)
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
// Transit rows fold their itinerary out inline (#1065).
const [expandedTransitIds, setExpandedTransitIds] = useState<Set<number>>(new Set())
@@ -977,6 +984,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onRouteRefresh,
onAddTransport,
onPlanTransit,
onPlanTransitLeg,
onOpenTransit,
onEditTransport,
expandedTransitIds,
@@ -1025,8 +1033,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
setLockHoverId,
undoHover,
setUndoHover,
icsHover,
setIcsHover,
hoveredAssignmentId,
setHoveredAssignmentId,
dropTargetKey,
@@ -1151,6 +1157,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onRouteRefresh,
onAddTransport,
onPlanTransit,
onPlanTransitLeg,
onOpenTransit,
onEditTransport,
expandedTransitIds,
@@ -1199,8 +1206,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
setLockHoverId,
undoHover,
setUndoHover,
icsHover,
setIcsHover,
hoveredAssignmentId,
setHoveredAssignmentId,
dropTargetKey,
@@ -1284,11 +1289,71 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
})
}
// Open the mode menu at the clicked connector: every route profile plus a
// "use day default" entry that clears the per-leg override.
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number) => {
// A connector's endpoints carry only coordinates (RouteSegment.from/to, both
// [lat, lng]); resolve them back to the NAMES the transit search shows by indexing
// every located place, hotel and booking endpoint on the trip. Name is a property
// of a coordinate, so a trip-wide index is fine; a connector only renders when both
// ends are located, so a coordinate that misses the index is rare (nameless pick).
const transitNameIndex = useMemo(() => {
const m = new Map<string, string>()
const put = (lat?: number | null, lng?: number | null, name?: string | null) => {
if (lat == null || lng == null || !name) return
const k = `${lat},${lng}`
if (!m.has(k)) m.set(k, name)
}
for (const list of Object.values(assignments)) {
for (const a of list) put(a.place?.lat, a.place?.lng, a.place?.name)
}
for (const acc of accommodations) put(acc.place_lat, acc.place_lng, acc.place_name)
for (const r of reservations) {
for (const ep of (r.endpoints || [])) put(ep.lat, ep.lng, ep.name)
}
return m
}, [assignments, accommodations, reservations])
// The leg's departure time must be resolved WITHIN its day: the same located POI can
// be revisited on another day at a different time (a supported pattern), so a
// trip-wide coordinate index would return the wrong day's time. Scope to this day's
// assignments (and the reservations that touch it) so a revisit keeps its own time.
const originDepartureTime = (originKey: string, dayId: number): string | null => {
for (const a of assignments[String(dayId)] ?? []) {
if (a.place?.lat != null && a.place?.lng != null && `${a.place.lat},${a.place.lng}` === originKey) return a.place.place_time ?? null
}
for (const r of reservations) {
if (r.day_id !== dayId && r.end_day_id !== dayId) continue
for (const ep of (r.endpoints || [])) {
if (ep.lat != null && ep.lng != null && `${ep.lat},${ep.lng}` === originKey) return ep.local_time ?? null
}
}
return null
}
// Build the "public transport" prefill for a leg from its RouteSegment: MOTIS is
// driven off the coordinates, the names come from the index and the departure time
// from the origin's own day. Accepts 'H:mm' or 'HH:mm[:ss]', normalised to 'HH:mm'.
const buildTransitLeg = (seg: RouteSegment | undefined, dayId: number): { from: PickedPlace; to: PickedPlace; time: string | null } | null => {
if (!seg?.from || !seg?.to) return null
const pick = (c: [number, number]): PickedPlace => ({ name: transitNameIndex.get(`${c[0]},${c[1]}`) || '', lat: c[0], lng: c[1] })
const rawTime = originDepartureTime(`${seg.from[0]},${seg.from[1]}`, dayId)
const m = rawTime ? /^(\d{1,2}):(\d{2})/.exec(rawTime) : null
return { from: pick(seg.from), to: pick(seg.to), time: m ? `${m[1].padStart(2, '0')}:${m[2]}` : null }
}
// The extra connector-menu entry (#1281 follow-up): search public transit for this
// leg instead of drawing a road route. Only when a handler is wired (day has dates).
const transitLegMenuItem = (dayId: number, seg?: RouteSegment) => {
if (!onPlanTransitLeg) return []
const leg = buildTransitLeg(seg, dayId)
if (!leg) return []
return [{ label: t('transit.title'), icon: TramFront, onClick: () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time }) }]
}
// Open the mode menu at the clicked connector: every route profile, the optional
// "public transport" entry, plus a "use day default" entry that clears the override.
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
ctxMenu.open(e, [
...routeProfileOptions.map(o => ({ label: o.label, icon: modeIcon(o.key), onClick: () => setLegMode(assignmentId, dayId, o.key) })),
...transitLegMenuItem(dayId, seg),
{ divider: true },
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setLegMode(assignmentId, dayId, null) },
])
@@ -1310,9 +1375,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
})
}
const openIncomingLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number) => {
const openIncomingLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
ctxMenu.open(e, [
...routeProfileOptions.map(o => ({ label: o.label, icon: modeIcon(o.key), onClick: () => setIncomingLegMode(assignmentId, dayId, o.key) })),
...transitLegMenuItem(dayId, seg),
{ divider: true },
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setIncomingLegMode(assignmentId, dayId, null) },
])
@@ -1334,8 +1400,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
t={t}
locale={locale}
toast={toast}
icsHover={icsHover}
setIcsHover={setIcsHover}
expandedDays={expandedDays}
setExpandedDays={setExpandedDays}
onUndo={onUndo}
@@ -1664,7 +1728,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const targetId = hotelLegs[day.id]?.top?.targetId
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.top!.seg} name={hotelLegs[day.id]!.top!.name} profile={routeProfile} placement="top" />
return canEditDays && targetId != null ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, targetId, day.id)} style={{ cursor: 'pointer' }}>
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} style={{ cursor: 'pointer' }}>
{connector}
</div>
) : connector
@@ -1906,33 +1970,38 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const confirmed = res.status === 'confirmed'
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
// The status, the time and the flight/train number used to sit in one
// pill strung together on a middle dot, which read as one run-on
// sentence. They are separate chips now: same tint so they still
// belong together, own outline so the eye can take them one at a time.
const RI = RES_ICONS[res.type] || Ticket
const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'
const chip: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '1px 6px', borderRadius: 5,
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
whiteSpace: 'nowrap',
}
const { time: st } = splitReservationDateTime(res.reservation_time)
const { time: et } = splitReservationDateTime(res.reservation_end_time)
const timeLabel = st || et
? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}`
: ''
let meta: any = {}
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
const carrierLabel = meta
? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '')
: ''
return (
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<div className={confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
}}>
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
// No wrapping: the time belongs to the status it qualifies, so the
// chips stay on one line even when the row gets narrow.
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'nowrap' }}>
<div className={tint} style={chip}>
<RI size={8} />
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
{(() => {
const { time: st } = splitReservationDateTime(res.reservation_time)
const { time: et } = splitReservationDateTime(res.reservation_end_time)
if (!st && !et) return null
return (
<span style={{ fontWeight: 400 }}>
{st ? formatTime(st, locale, timeFormat) : ''}
{et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}
</span>
)
})()}
{(() => {
let meta: any = {}
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
if (!meta) return null
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
return null
})()}
</div>
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
{hasEndpoints && (
<button
type="button"
@@ -2034,7 +2103,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
{daySchedule.byAssignment[day.id]?.[assignment.id]?.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
{routeLegs[day.id]?.[assignment.id] && (canEditDays ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id)} style={{ cursor: 'pointer' }}>
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} style={{ cursor: 'pointer' }}>
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
</div>
) : (
@@ -2273,7 +2342,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const nextPlaceId = merged.slice(idx + 1).find(i => i.type === 'place' && i.data.place?.lat && i.data.place?.lng)?.data.id
const connector = <RouteConnector seg={routeLegs[day.id]![res.id]} profile={routeProfile} />
return canEditDays && nextPlaceId != null ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id)} style={{ cursor: 'pointer' }}>
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} style={{ cursor: 'pointer' }}>
{connector}
</div>
) : connector
@@ -2407,7 +2476,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const targetId = hotelLegs[day.id]?.bottom?.targetId
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.bottom!.seg} name={hotelLegs[day.id]!.bottom!.name} profile={routeProfile} placement="bottom" />
return canEditDays && targetId != null ? (
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, targetId, day.id)} style={{ cursor: 'pointer' }}>
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} style={{ cursor: 'pointer' }}>
{connector}
</div>
) : connector
@@ -1,15 +1,15 @@
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-022
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-023
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF'
import { buildDay, buildDayNote, buildReservation, buildTrip } from '../../../tests/helpers/factories'
import { buildDay, buildReservation, buildTrip } from '../../../tests/helpers/factories'
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
import type { Reservation } from '../../types'
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
// The subscribe dialog fetches its feed token on mount; it is exercised in its
// own test, here we only care that opening the menu entry mounts it.
// own test, and TripExportModal covers the entry that opens it.
vi.mock('./IcsSubscribeModal', () => ({
IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => (
<div data-testid="ics-subscribe-modal">
@@ -46,8 +46,6 @@ function makeProps(overrides: Partial<React.ComponentProps<typeof DayPlanSidebar
t,
locale: 'en-US',
toast: makeToast(),
icsHover: false,
setIcsHover: vi.fn((_v: boolean) => {}),
expandedDays: new Set<number>(),
setExpandedDays: vi.fn((_v: Set<number>) => {}),
canUndo: false,
@@ -72,37 +70,42 @@ beforeEach(() => {
})
describe('DayPlanSidebarToolbar', () => {
// ── PDF export ────────────────────────────────────────────────────────────
// ── Export ─────────────────────────────────────────────────────
// PDF, ICS and GPX each used to be their own button with its own hover menu.
// The mechanics now live in TripExportModal and are tested there; what is left
// here is the single button that opens it.
it('FE-PLANNER-DPTOOLBAR-001: the PDF button exports the trip with the day notes flattened', async () => {
const user = userEvent.setup()
const days = [buildDay({ id: 10, title: 'Day 1' })]
const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] }
render(<DayPlanSidebarToolbar {...makeProps({ days, dayNotes })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
})
it('FE-PLANNER-DPTOOLBAR-001: the toolbar carries one export button, not three', () => {
render(<DayPlanSidebarToolbar {...makeProps()} />)
expect(screen.getByRole('button', { name: 'dayplan.export' })).toBeInTheDocument()
expect(screen.queryByText('ICS')).not.toBeInTheDocument()
expect(screen.queryByText('GPX')).not.toBeInTheDocument()
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-002: a failing PDF export surfaces the error via the toast', async () => {
const user = userEvent.setup()
vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing'))
const toast = makeToast()
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'))
})
it('FE-PLANNER-DPTOOLBAR-003: hovering the PDF button shows the export tooltip', async () => {
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument()
await user.hover(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(screen.getByText('dayplan.pdfTooltip')).toBeInTheDocument())
await user.unhover(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument())
const btn = screen.getByRole('button', { name: 'dayplan.export' })
expect(btn).toHaveAttribute('aria-expanded', 'false')
await user.click(btn)
expect(btn).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument()
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
await waitFor(() => expect(screen.queryByText('dayplan.exportDocument')).not.toBeInTheDocument())
})
it('FE-PLANNER-DPTOOLBAR-003: hovering the export button shows its tooltip', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument()
const btn = screen.getByRole('button', { name: 'dayplan.export' })
await user.hover(btn)
await waitFor(() => expect(screen.getByText('dayplan.exportIntro')).toBeInTheDocument())
await user.unhover(btn)
await waitFor(() => expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument())
})
// The button sits at the right edge of the leftmost pane. Its own tooltip was
@@ -111,8 +114,8 @@ describe('DayPlanSidebarToolbar', () => {
it('FE-PLANNER-DPTOOLBAR-004: the export tooltip stays inside the window', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
await user.hover(screen.getByText('dayplan.pdf'))
const tip = await screen.findByText('dayplan.pdfTooltip')
await user.hover(screen.getByRole('button', { name: 'dayplan.export' }))
const tip = await screen.findByText('dayplan.exportIntro')
expect(tip.closest('[role="tooltip"]')).not.toBeNull()
expect(tip.parentElement).toBe(document.body)
expect(tip.style.position).toBe('fixed')
@@ -120,94 +123,12 @@ describe('DayPlanSidebarToolbar', () => {
await waitFor(() => expect(parseFloat(tip.style.left)).toBeGreaterThanOrEqual(0))
})
// ── ICS menu ──────────────────────────────────────────────────────────────
it('FE-PLANNER-DPTOOLBAR-005: hovering ICS opens the download/subscribe menu', async () => {
const user = userEvent.setup()
const setIcsHover = vi.fn((_v: boolean) => {})
render(<DayPlanSidebarToolbar {...makeProps({ setIcsHover })} />)
expect(screen.queryByText('Download ICS')).not.toBeInTheDocument()
await user.hover(screen.getByText('ICS'))
expect(screen.getByText('Download ICS')).toBeInTheDocument()
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
expect(setIcsHover).toHaveBeenCalledWith(true)
})
it('FE-PLANNER-DPTOOLBAR-005b: without share_manage the menu offers the download but not the subscription', async () => {
// The subscription mints a link that reads the trip without an account, so
// it needs share_manage; the one-off download is a file this member may
// already read. Leaving the entry visible would only produce a dialog whose
// enable button the server refuses.
it('FE-PLANNER-DPTOOLBAR-005: the dialog inherits the share permission from the toolbar', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps({ canManageShare: false })} />)
await user.hover(screen.getByText('ICS'))
expect(screen.getByText('Download ICS')).toBeInTheDocument()
expect(screen.queryByText('Subscribe to calendar')).not.toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-006: leaving the ICS area closes the menu after the grace period', async () => {
const user = userEvent.setup()
const setIcsHover = vi.fn((_v: boolean) => {})
render(<DayPlanSidebarToolbar {...makeProps({ setIcsHover })} />)
const icsButton = screen.getByText('ICS')
await user.hover(icsButton)
expect(screen.getByText('Download ICS')).toBeInTheDocument()
await user.unhover(icsButton)
await waitFor(() => expect(screen.queryByText('Download ICS')).not.toBeInTheDocument())
expect(setIcsHover).toHaveBeenLastCalledWith(false)
})
it('FE-PLANNER-DPTOOLBAR-007: re-entering the ICS area cancels the pending close', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
const icsButton = screen.getByText('ICS')
await user.hover(icsButton)
await user.unhover(icsButton)
await user.hover(icsButton)
// The close is scheduled 120ms out; coming back in has to cancel it.
await new Promise(r => setTimeout(r, 200))
expect(screen.getByText('Download ICS')).toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-008: "Download ICS" fetches the export and hands it to a download link', async () => {
const user = userEvent.setup()
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
} as unknown as Response)
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
render(<DayPlanSidebarToolbar {...makeProps()} />)
await user.hover(screen.getByText('ICS'))
await user.click(screen.getByText('Download ICS'))
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' }))
await waitFor(() => expect(clickSpy).toHaveBeenCalled())
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
// The menu closes itself once the download starts.
expect(screen.queryByText('Download ICS')).not.toBeInTheDocument()
fetchSpy.mockRestore(); createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
})
it('FE-PLANNER-DPTOOLBAR-009: a rejected ICS export shows the failure toast', async () => {
const user = userEvent.setup()
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false } as unknown as Response)
const toast = makeToast()
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
await user.hover(screen.getByText('ICS'))
await user.click(screen.getByText('Download ICS'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed'))
fetchSpy.mockRestore()
})
it('FE-PLANNER-DPTOOLBAR-010: "Subscribe to calendar" opens the subscribe dialog and closes again', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
await user.hover(screen.getByText('ICS'))
await user.click(screen.getByText('Subscribe to calendar'))
expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument()
await user.click(screen.getByText('close-subscribe'))
expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dayplan.export' }))
expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument()
expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument()
})
// ── Expand / collapse all ────────────────────────────────────────────────
@@ -350,105 +271,6 @@ describe('DayPlanSidebarToolbar', () => {
expect(routeBtn.style.background).toBe('var(--bg-hover)')
await user.unhover(routeBtn)
expect(routeBtn.style.background).toBe('transparent')
await user.hover(screen.getByText('ICS'))
const download = screen.getByText('Download ICS')
const subscribe = screen.getByText('Subscribe to calendar')
await user.hover(download)
expect(download.style.background).toContain('var(--bg-hover')
// Sliding down to the next entry has to clear the one we left.
await user.hover(subscribe)
expect(download.style.background).toBe('transparent')
expect(subscribe.style.background).toContain('var(--bg-hover')
await user.unhover(subscribe)
expect(subscribe.style.background).toBe('transparent')
})
it('FE-PLANNER-DPTOOLBAR-024: an aborted PDF export without an Error still reaches the toast', async () => {
const user = userEvent.setup()
vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom')
const toast = makeToast()
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
})
// ── GPX export (#1442) ────────────────────────────────────────────────────
describe('GPX menu', () => {
let clickedHref: string | null
beforeEach(() => {
clickedHref = null
// jsdom has neither of these, and an anchor click would navigate.
globalThis.URL.createObjectURL = vi.fn(() => 'blob:gpx')
globalThis.URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
clickedHref = this.href
})
})
afterEach(() => vi.restoreAllMocks())
const okResponse = () => ({ ok: true, status: 200, blob: async () => new Blob(['<gpx/>']) }) as unknown as Response
it('FE-PLANNER-DPTOOLBAR-025: the menu offers the three scopes on hover', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
expect(screen.queryByText('dayplan.gpxAll')).not.toBeInTheDocument()
await user.hover(screen.getByText('GPX'))
expect(screen.getByText('dayplan.gpxAll')).toBeInTheDocument()
expect(screen.getByText('dayplan.gpxPlaces')).toBeInTheDocument()
expect(screen.getByText('dayplan.gpxDays')).toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-026: each scope asks the server for exactly its own selection', async () => {
const user = userEvent.setup()
const fetchMock = vi.fn(async () => okResponse())
vi.stubGlobal('fetch', fetchMock)
render(<DayPlanSidebarToolbar {...makeProps()} />)
for (const [label, expected] of [
['dayplan.gpxAll', '/api/trips/1/places/export.gpx'],
['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'],
['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'],
] as const) {
await user.hover(screen.getByText('GPX'))
await user.click(screen.getByText(label))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' }))
fetchMock.mockClear()
}
})
it('FE-PLANNER-DPTOOLBAR-027: a download names the file after the trip', async () => {
const user = userEvent.setup()
vi.stubGlobal('fetch', vi.fn(async () => okResponse()))
render(<DayPlanSidebarToolbar {...makeProps()} />)
await user.hover(screen.getByText('GPX'))
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(clickedHref).toBe('blob:gpx'))
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled()
})
it('FE-PLANNER-DPTOOLBAR-028: an empty trip says so instead of reporting a failure', async () => {
const user = userEvent.setup()
const toast = makeToast()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response))
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
await user.hover(screen.getByText('GPX'))
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'))
expect(toast.error).not.toHaveBeenCalled()
expect(clickedHref).toBeNull()
})
it('FE-PLANNER-DPTOOLBAR-029: a real failure toasts the error', async () => {
const user = userEvent.setup()
const toast = makeToast()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
await user.hover(screen.getByText('GPX'))
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
})
})
})
@@ -1,24 +1,12 @@
import React, { useState, useRef } from 'react'
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus, MapPin, Route as RouteIcon } from 'lucide-react'
import { useState } from 'react'
import { ChevronsDownUp, ChevronsUpDown, Download, Undo2, ArrowUpDown, Route as RouteIcon } from 'lucide-react'
import { DayReorderPopup } from './DayReorderPopup'
import Tooltip from '../shared/Tooltip'
import { useToast } from '../shared/Toast'
import { IcsSubscribeModal } from './IcsSubscribeModal'
import { TripExportModal } from './TripExportModal'
import { isRoutableReservation } from '../../utils/reservationRoutes'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
/**
* What a GPX download can carry. Worded by what someone wants on their device
* rather than by the GPX element it maps to: "everything", "just the places" for
* an offline map, "just the days" for following a plan. An omitted flag defaults
* to true server-side, so the first entry needs no query at all.
*/
const GPX_SCOPES = [
{ key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: FileDown },
{ key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin },
{ key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon },
] as const
interface DayPlanSidebarToolbarProps {
tripId: number
trip: Trip
@@ -33,8 +21,6 @@ interface DayPlanSidebarToolbarProps {
t: (key: string, params?: Record<string, any>) => string
locale: string
toast: ReturnType<typeof useToast>
icsHover: boolean
setIcsHover: (v: boolean) => void
expandedDays: Set<number>
setExpandedDays: (next: Set<number>) => void
onUndo?: () => void
@@ -44,11 +30,9 @@ interface DayPlanSidebarToolbarProps {
lastActionLabel: string | null
canEditDays?: boolean
/**
* Gates "Subscribe to calendar" only. The one-off "Download ICS" below it
* stays open to every member: it is a file they already have the right to
* read, while the subscription mints a link that works without an account.
* Defaults to true so a caller that has not wired the permission through
* keeps today's menu rather than silently losing an entry.
* Gates "Subscribe to calendar" in the export dialog only. Defaults to true so
* a caller that has not wired the permission through keeps today's entries
* rather than silently losing one.
*/
canManageShare?: boolean
onReorderDays?: (orderedIds: number[]) => void
@@ -58,230 +42,53 @@ interface DayPlanSidebarToolbarProps {
export function DayPlanSidebarToolbar({
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
allConnectionsShown = false, onToggleAllConnections,
t, locale, toast, setIcsHover,
t, locale, toast,
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
canEditDays, canManageShare = true, onReorderDays, onAddDay,
}: DayPlanSidebarToolbarProps) {
const [reorderOpen, setReorderOpen] = useState(false)
const [subscribeOpen, setSubscribeOpen] = useState(false)
const icsMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [icsMenuVisible, setIcsMenuVisible] = useState(false)
const gpxMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [gpxMenuVisible, setGpxMenuVisible] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const showIcsMenu = () => {
if (icsMenuTimeoutRef.current) clearTimeout(icsMenuTimeoutRef.current)
setIcsMenuVisible(true)
setIcsHover(true)
}
const hideIcsMenu = () => {
icsMenuTimeoutRef.current = setTimeout(() => {
setIcsMenuVisible(false)
setIcsHover(false)
}, 120)
}
const showGpxMenu = () => {
if (gpxMenuTimeoutRef.current) clearTimeout(gpxMenuTimeoutRef.current)
setGpxMenuVisible(true)
}
const hideGpxMenu = () => {
gpxMenuTimeoutRef.current = setTimeout(() => setGpxMenuVisible(false), 120)
}
const downloadGpx = async (query: string) => {
setGpxMenuVisible(false)
try {
const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' })
// 404 here means the selection is empty, which is worth its own message:
// "nothing happened" and "the download broke" look identical otherwise.
if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return }
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.gpx`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error(t('dayplan.gpxFailed')) }
}
const menuItemStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 7,
width: '100%', padding: '7px 12px', border: 'none',
background: 'transparent', cursor: 'pointer',
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
color: 'var(--text-primary)', textAlign: 'left',
transition: 'background 0.1s',
}
return (
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
<div style={{ position: 'relative', flexShrink: 0 }}>
<Tooltip label={t('dayplan.pdfTooltip')} placement="bottom">
{/* One export button instead of three: PDF, ICS and GPX each carried
their own hover menu, and on a narrower sidebar the row ran out of
width and pushed them off the edge. The dialog holds every option. */}
<Tooltip label={t('dayplan.exportIntro')} placement="bottom">
<button
onClick={async () => {
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
notes.map(n => ({ ...n, day_id: Number(dayId) }))
)
try {
// Loaded on click: the PDF builder is ~226 kB and hangs off the
// days sidebar, so every trip used to pay for it whether or not
// anyone exported. A missing chunk lands in the catch below and
// shows the same error the export already had.
const { downloadTripPDF } = await import('../PDF/TripPDF')
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
} catch (e) {
console.error('PDF error:', e)
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
}
}}
type="button"
onClick={() => setExportOpen(true)}
aria-haspopup="dialog"
aria-expanded={exportOpen}
className="bg-accent text-accent-text"
style={{
display: 'flex', alignItems: 'center', gap: 5,
display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0,
padding: '5px 10px', borderRadius: 8, border: 'none',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
{t('dayplan.pdf')}
<Download size={13} strokeWidth={2} />
{t('dayplan.export')}
</button>
</Tooltip>
</div>
<div
style={{ position: 'relative', flexShrink: 0 }}
onMouseEnter={showIcsMenu}
onMouseLeave={hideIcsMenu}
>
<button
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
ICS
</button>
{icsMenuVisible && (
<div
onMouseEnter={showIcsMenu}
onMouseLeave={hideIcsMenu}
style={{
position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
zIndex: 200, minWidth: 160,
background: 'var(--bg-card, white)',
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
border: '1px solid var(--border-faint, #e5e7eb)',
overflow: 'hidden',
}}
>
<button
onClick={async () => {
setIcsMenuVisible(false)
setIcsHover(false)
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.ics`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error(t('planner.icsExportFailed')) }
}}
style={menuItemStyle}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<FileDown size={12} strokeWidth={2} />
Download ICS
</button>
{canManageShare && (
<button
onClick={() => {
setIcsMenuVisible(false)
setIcsHover(false)
setSubscribeOpen(true)
}}
style={menuItemStyle}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<CalendarPlus size={12} strokeWidth={2} />
Subscribe to calendar
</button>
)}
</div>
)}
</div>
{/* GPX — the counterpart to the GPX import in the places sidebar, sitting
with the other exports rather than with the importer. Same hover-menu
shape as ICS beside it. */}
<div
style={{ position: 'relative', flexShrink: 0 }}
onMouseEnter={showGpxMenu}
onMouseLeave={hideGpxMenu}
>
<button
aria-haspopup="menu"
aria-expanded={gpxMenuVisible}
aria-label={t('dayplan.gpxTooltip')}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
GPX
</button>
{gpxMenuVisible && (
<div
role="menu"
onMouseEnter={showGpxMenu}
onMouseLeave={hideGpxMenu}
style={{
position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
zIndex: 200, minWidth: 190,
background: 'var(--bg-card, white)',
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
border: '1px solid var(--border-faint, #e5e7eb)',
overflow: 'hidden',
}}
>
{GPX_SCOPES.map(scope => (
<button
key={scope.key}
role="menuitem"
onClick={() => downloadGpx(scope.query)}
style={menuItemStyle}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<scope.icon size={12} strokeWidth={2} />
{t(scope.labelKey)}
</button>
))}
</div>
)}
</div>
{subscribeOpen && canManageShare && (
<IcsSubscribeModal
endpoint={`/api/trips/${tripId}/feed`}
title="Subscribe to calendar"
description="This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour."
onClose={() => setSubscribeOpen(false)}
/>
)}
</Tooltip>
<TripExportModal
isOpen={exportOpen}
onClose={() => setExportOpen(false)}
tripId={tripId}
trip={trip}
days={days}
places={places}
categories={categories}
assignments={assignments}
reservations={reservations}
dayNotes={dayNotes}
t={t}
locale={locale}
toast={toast}
canManageShare={canManageShare}
/>
{(() => {
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
@@ -90,7 +90,7 @@ export function DayPlanSidebarTransportDetailModal({
<div className={res.status === 'confirmed' ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{
padding: '3px 8px', borderRadius: 6, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
}}>
{(res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')}
{res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')}
</div>
</div>
@@ -1,4 +1,4 @@
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-062
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -072
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -1467,4 +1467,94 @@ describe('PlaceFormModal remaining branches', () => {
expect(websiteInput().value).toBe('https://stored.example');
});
});
// ── Linked expense (#1298) — the same block bookings have ──────────────────
describe('Costs section', () => {
const withBudget = () => seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
it('FE-PLANNER-PLACEFORM-068: the block only appears while the Budget addon is on', () => {
const { unmount } = render(<PlaceFormModal {...defaultProps} />);
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
unmount();
withBudget();
render(<PlaceFormModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-069: creating an expense saves the place first, then opens the editor', async () => {
withBudget();
const onSave = vi.fn().mockResolvedValue({ id: 42 });
const onOpenExpense = vi.fn();
render(<PlaceFormModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await waitFor(() => expect(onOpenExpense).toHaveBeenCalled());
expect(onSave).toHaveBeenCalled();
expect(onSave.mock.invocationCallOrder[0]).toBeLessThan(onOpenExpense.mock.invocationCallOrder[0]);
expect(onOpenExpense).toHaveBeenCalledWith({
prefill: { placeId: 42, name: 'Louvre', category: 'activities' },
});
});
it('FE-PLANNER-PLACEFORM-070: a save that yields no id opens no editor', async () => {
withBudget();
const onSave = vi.fn().mockResolvedValue(undefined);
const onOpenExpense = vi.fn();
render(<PlaceFormModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onOpenExpense).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLACEFORM-070b: a nameless click drops the intent instead of arming the next save', async () => {
withBudget();
const onSave = vi.fn().mockResolvedValue({ id: 42 });
const onOpenExpense = vi.fn();
render(<PlaceFormModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
// No name yet — the save is refused and the intent must not survive it.
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await waitFor(() => expect(onSave).not.toHaveBeenCalled());
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onOpenExpense).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLACEFORM-071: an already-linked expense is shown with its amount instead of the button', () => {
withBudget();
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [{ id: 8, trip_id: 1, name: 'Louvre tickets', total_price: 34, category: 'activities', place_id: 7 }],
});
render(<PlaceFormModal {...defaultProps} place={{ id: 7, name: 'Louvre' } as never} />);
expect(screen.getByText('Louvre tickets')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-072: an expense linked to another place is not claimed', () => {
withBudget();
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [{ id: 8, trip_id: 1, name: 'Orsay tickets', total_price: 16, category: 'activities', place_id: 99 }],
});
render(<PlaceFormModal {...defaultProps} place={{ id: 7, name: 'Louvre' } as never} />);
expect(screen.queryByText('Orsay tickets')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
});
});
});
@@ -16,7 +16,9 @@ import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
import { getApiErrorMessage } from '../../utils/apiError'
import type { Place, Category, Assignment } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { Place, Category, Assignment, BudgetItem } from '../../types'
import { NumericInput } from '../shared/NumericInput'
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
@@ -31,7 +33,7 @@ export interface PlaceSubmitData extends Omit<PlaceFormData, 'lat' | 'lng' | 'ca
interface PlaceFormModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
place: Place | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
tripId: number
@@ -43,6 +45,9 @@ interface PlaceFormModalProps {
* picker column when the Collections addon is enabled. Sourced from the trip
* planner's matchMedia('(max-width:767px)'). */
isMobile?: boolean
/** Opens the Costs editor for this place's linked expense (#1298) — the same
* seam the booking and transport modals use. */
onOpenExpense?: (req: BookingExpenseRequest) => void
}
@@ -78,6 +83,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const {
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false,
onOpenExpense,
} = props
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
@@ -108,6 +114,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const tripObj = useTripStore((s) => s.trip)
const canUploadFiles = can('file_upload', tripObj)
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'))
const isBudgetEnabled = useAddonStore((s) => s.isEnabled('budget'))
const deleteBudgetItem = useTripStore((s) => s.deleteBudgetItem)
// Set right before submit when the user clicked create/edit expense — the
// place has to exist before an expense can point at it (see ReservationModal).
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
useEffect(() => {
if (place) {
@@ -430,9 +441,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const hasTimeError = place && form.place_time && form.end_time && form.place_time.length >= 5 && form.end_time.length >= 5 && form.end_time <= form.place_time
const handleSubmit = async (e) => {
e.preventDefault()
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
e?.preventDefault?.()
if (!form.name.trim()) {
// Nothing was saved, so an expense intent from a previous click is stale —
// otherwise the next plain Save would open a Costs editor out of nowhere.
expenseIntentRef.current = null
toast.error(t('places.nameRequired'))
return
}
@@ -449,13 +463,23 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
setIsSaving(true)
try {
await onSave({
const saved = await onSave({
...form,
lat: form.lat ? parseFloat(form.lat) : null,
lng: form.lng ? parseFloat(form.lng) : null,
category_id: form.category_id || null,
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
})
// Open the Costs editor for the saved place when the user asked to
// create/edit its linked expense — gated on an id, so a create that the
// server refused never opens an editor pointing at nothing (#1298).
const intent = expenseIntentRef.current
expenseIntentRef.current = null
const savedId = (saved && 'id' in saved ? saved.id : null) ?? place?.id ?? null
if (intent && onOpenExpense && savedId) {
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
else onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } })
}
onClose()
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('places.saveError'))
@@ -464,6 +488,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
}
}
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; void handleSubmit() }
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; void handleSubmit() }
const handleRemoveExpense = async (item: BudgetItem) => {
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
}
return {
isOpen,
onClose,
@@ -527,6 +557,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
handleSubmit,
duplicateWarning,
detailsSelection,
isBudgetEnabled,
handleCreateExpense,
handleEditExpense,
handleRemoveExpense,
}
}
@@ -593,6 +627,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
handleSubmit,
duplicateWarning,
detailsSelection,
isBudgetEnabled,
handleCreateExpense,
handleEditExpense,
handleRemoveExpense,
} = S
// Desktop + Collections addon → the saved-place picker on the right. Mobile
// always keeps the original single-column form untouched.
@@ -917,6 +955,19 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
</div>
)}
{/* Costs — create / view the expense linked to this place (#1298).
Same block, same flow as a booking: save first, then the editor. */}
{isBudgetEnabled && (
<BookingCostsSection
placeId={place?.id ?? null}
reservationId={null}
hintKey="places.createExpenseHint"
onCreate={handleCreateExpense}
onEdit={handleEditExpense}
onRemove={handleRemoveExpense}
/>
)}
</form>
{twoColumn && (
<CollectionPicker bias={locationBias} onSelect={handleSelectMapsResult} t={t} />
@@ -1,9 +1,9 @@
import { Check, Tag, Trash2, Bookmark } from 'lucide-react'
import { Check, CheckCircle2, Loader2, Tag, Trash2, Bookmark } from 'lucide-react'
import Tooltip from '../shared/Tooltip'
import type { SidebarState } from './usePlacesSidebar'
export function PlacesSelectionBar(S: SidebarState) {
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen, collectionsEnabled, setSaveToListOpen } = S
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen, collectionsEnabled, setSaveToListOpen, markSelectionVisited, markVisitedBusy } = S
return (
<div style={{
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
@@ -68,6 +68,25 @@ export function PlacesSelectionBar(S: SidebarState) {
</button>
</Tooltip>
)}
{collectionsEnabled && (
<Tooltip label={t('collections.markVisitedSelection')} placement="bottom">
<button
onClick={() => { if (selectedIds.size === 0) return; void markSelectionVisited() }}
disabled={selectedIds.size === 0 || markVisitedBusy}
aria-label={t('collections.markVisitedSelection')}
className={selectedIds.size > 0 ? 'bg-transparent text-content-muted' : 'bg-transparent text-content-faint'}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: 6, border: 'none',
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
}}
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
{markVisitedBusy ? <Loader2 size={13} className="animate-spin" /> : <CheckCircle2 size={13} strokeWidth={2} />}
</button>
</Tooltip>
)}
<Tooltip label={t('places.deleteSelected')} placement="bottom">
<button
onClick={() => {
@@ -177,6 +177,20 @@ describe('TransitSearchPanel', () => {
expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ arriveBy: true, time: '2025-06-01T08:00:00.000Z' })
})
it('FE-PLANNER-TRANSIT-008b: initialTime seeds the departure time', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
render(<TransitSearchPanel {...makeProps({
initialFrom: { name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 },
initialTo: { name: 'London Victoria', lat: 51.5074, lng: -0.1278 },
initialTime: '11:00',
})} />)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled())
// 11:00 in Berlin (UTC+2) anchors depart-by to the origin zone → 09:00Z.
expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ time: '2025-06-01T09:00:00.000Z' })
})
it('FE-PLANNER-TRANSIT-009: arrive-by lists the latest-arriving itinerary first', async () => {
const user = userEvent.setup()
// MOTIS returns arrive-by results ascending, deadline-adjacent last (#1479).
@@ -318,9 +318,11 @@ interface TransitSearchPanelProps {
/** Pre-seed from/to — used by "change route" on an existing journey. */
initialFrom?: PickedPlace | null
initialTo?: PickedPlace | null
/** Pre-seed the departure time ('HH:mm') — used when planning a specific leg. */
initialTime?: string | null
}
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null }: TransitSearchPanelProps) {
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
const { t } = useTranslation()
const toast = useToast()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
@@ -328,7 +330,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
const [to, setTo] = useState<PickedPlace | null>(initialTo)
const [time, setTime] = useState('09:00')
const [time, setTime] = useState(initialTime || '09:00')
const [arriveBy, setArriveBy] = useState(false)
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
@@ -160,8 +160,8 @@ interface TransportModalProps {
initialAutomated?: boolean
/** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */
tripHasDates?: boolean
/** Pre-seed the transit search — used by "change route" on an existing journey. */
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null } | null
/** Pre-seed the transit search — used by "change route" and by per-leg planning. */
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null; time?: string | null } | null
/** Trip members + guests, for the traveler picker (#1517). */
tripMembers?: TripMember[]
}
@@ -701,6 +701,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
onAdd={(payload) => onSave(payload as Record<string, any> & { title: string })}
initialFrom={transitPrefill?.from ?? null}
initialTo={transitPrefill?.to ?? null}
initialTime={transitPrefill?.time ?? null}
/>
)
})()}
@@ -0,0 +1,222 @@
// FE-PLANNER-EXPORTMODAL-001 to FE-PLANNER-EXPORTMODAL-014
import { render, screen, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF'
import { buildDay, buildDayNote, buildTrip } from '../../../tests/helpers/factories'
import { TripExportModal } from './TripExportModal'
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
// The subscribe dialog fetches its feed token on mount; it is exercised in its
// own test, here we only care that the entry mounts it.
vi.mock('./IcsSubscribeModal', () => ({
IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => (
<div data-testid="ics-subscribe-modal">
{title}
<button onClick={onClose}>close-subscribe</button>
</div>
),
}))
const t = (key: string, params?: Record<string, unknown>) =>
params ? `${key}|${Object.values(params).join('|')}` : key
const trip = buildTrip({ id: 1, title: 'Roadtrip' })
function makeToast() {
return {
success: vi.fn((_m: string) => {}),
error: vi.fn((_m: string) => {}),
warning: vi.fn((_m: string) => {}),
info: vi.fn((_m: string) => {}),
}
}
function makeProps(overrides: Partial<React.ComponentProps<typeof TripExportModal>> = {}) {
return {
isOpen: true,
onClose: vi.fn(),
tripId: 1,
trip,
days: [],
places: [],
categories: [],
assignments: {},
reservations: [],
dayNotes: {},
t,
locale: 'en-US',
toast: makeToast(),
...overrides,
} as React.ComponentProps<typeof TripExportModal>
}
let clickedHref: string | null
beforeEach(() => {
vi.clearAllMocks()
clickedHref = null
// jsdom has neither of these, and an anchor click would navigate.
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock')
globalThis.URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
clickedHref = this.href
})
})
afterEach(() => vi.restoreAllMocks())
const okResponse = (body = '<gpx/>') =>
({ ok: true, status: 200, blob: async () => new Blob([body]) }) as unknown as Response
describe('TripExportModal', () => {
it('FE-PLANNER-EXPORTMODAL-001: closed, it renders nothing', () => {
render(<TripExportModal {...makeProps({ isOpen: false })} />)
expect(screen.queryByText('dayplan.export')).not.toBeInTheDocument()
})
it('FE-PLANNER-EXPORTMODAL-002: open, every export sits in its own section', () => {
render(<TripExportModal {...makeProps()} />)
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument()
expect(screen.getByText('dayplan.exportMaps · GPX')).toBeInTheDocument()
for (const label of ['dayplan.pdf', 'mobileTrip.icsDownload', 'mobileTrip.icsSubscribe',
'dayplan.gpxAll', 'dayplan.gpxPlaces', 'dayplan.gpxDays']) {
expect(screen.getByText(label)).toBeInTheDocument()
}
})
// ── PDF ───────────────────────────────────────────────────────────────────
it('FE-PLANNER-EXPORTMODAL-003: the PDF row exports the trip with the day notes flattened', async () => {
const user = userEvent.setup()
const days = [buildDay({ id: 10, title: 'Day 1' })]
const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] }
render(<TripExportModal {...makeProps({ days, dayNotes })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
})
})
it('FE-PLANNER-EXPORTMODAL-004: a finished PDF export closes the dialog', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<TripExportModal {...makeProps({ onClose })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-PLANNER-EXPORTMODAL-005: a failing PDF export toasts the error and keeps the dialog open', async () => {
const user = userEvent.setup()
vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing'))
const toast = makeToast()
const onClose = vi.fn()
render(<TripExportModal {...makeProps({ toast, onClose })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-PLANNER-EXPORTMODAL-006: an aborted PDF export without an Error still reaches the toast', async () => {
const user = userEvent.setup()
vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom')
const toast = makeToast()
render(<TripExportModal {...makeProps({ toast })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
})
// ── Calendar ──────────────────────────────────────────────────────────────
it('FE-PLANNER-EXPORTMODAL-007: the ICS row fetches the export and hands it to a download link', async () => {
const user = userEvent.setup()
const fetchMock = vi.fn(async () => okResponse('BEGIN:VCALENDAR'))
vi.stubGlobal('fetch', fetchMock)
render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText('mobileTrip.icsDownload'))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' }))
await waitFor(() => expect(clickedHref).toBe('blob:mock'))
})
it('FE-PLANNER-EXPORTMODAL-008: a rejected ICS export shows the failure toast', async () => {
const user = userEvent.setup()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
const toast = makeToast()
render(<TripExportModal {...makeProps({ toast })} />)
await user.click(screen.getByText('mobileTrip.icsDownload'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed'))
expect(clickedHref).toBeNull()
})
it('FE-PLANNER-EXPORTMODAL-009: the subscribe row opens the subscribe dialog and closes again', async () => {
const user = userEvent.setup()
render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText('mobileTrip.icsSubscribe'))
expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument()
await user.click(screen.getByText('close-subscribe'))
expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument()
})
// The subscription mints a link that reads the trip without an account, so it
// needs share_manage; the one-off download is a file this member may already
// read. Leaving the entry visible would only produce a dialog whose enable
// button the server refuses.
it('FE-PLANNER-EXPORTMODAL-010: without share_manage the download stays and the subscription goes', () => {
render(<TripExportModal {...makeProps({ canManageShare: false })} />)
expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument()
expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument()
})
// ── GPX (#1442) ───────────────────────────────────────────────────────────
it('FE-PLANNER-EXPORTMODAL-011: each scope asks the server for exactly its own selection', async () => {
const user = userEvent.setup()
const fetchMock = vi.fn(async () => okResponse())
vi.stubGlobal('fetch', fetchMock)
for (const [label, expected] of [
['dayplan.gpxAll', '/api/trips/1/places/export.gpx'],
['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'],
['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'],
] as const) {
const view = render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText(label))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' }))
fetchMock.mockClear()
view.unmount()
}
})
it('FE-PLANNER-EXPORTMODAL-012: a download names the file after the trip', async () => {
const user = userEvent.setup()
vi.stubGlobal('fetch', vi.fn(async () => okResponse()))
render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(clickedHref).toBe('blob:mock'))
await waitFor(() => expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled())
})
it('FE-PLANNER-EXPORTMODAL-013: an empty trip says so instead of reporting a failure', async () => {
const user = userEvent.setup()
const toast = makeToast()
const onClose = vi.fn()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response))
render(<TripExportModal {...makeProps({ toast, onClose })} />)
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'))
expect(toast.error).not.toHaveBeenCalled()
expect(clickedHref).toBeNull()
// Nothing was downloaded, so the dialog stays put.
expect(onClose).not.toHaveBeenCalled()
})
it('FE-PLANNER-EXPORTMODAL-014: a real failure toasts the error', async () => {
const user = userEvent.setup()
const toast = makeToast()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
render(<TripExportModal {...makeProps({ toast })} />)
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
})
})
@@ -0,0 +1,246 @@
import { useState, type ReactNode } from 'react'
import { CalendarDays, CalendarPlus, ChevronRight, Download, FileText, Loader2, MapPin, Route as RouteIcon } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import Modal from '../shared/Modal'
import { IcsSubscribeModal } from './IcsSubscribeModal'
import { useToast } from '../shared/Toast'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
/**
* What a GPX download can carry. Worded by what someone wants on their device
* rather than by the GPX element it maps to: "everything", "just the places" for
* an offline map, "just the days" for following a plan. An omitted flag defaults
* to true server-side, so the first entry needs no query at all.
*/
const GPX_SCOPES = [
{ key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: Download },
{ key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin },
{ key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon },
] as const
interface TripExportModalProps {
isOpen: boolean
onClose: () => void
tripId: number
trip: Trip
days: Day[]
places: Place[]
categories: Category[]
assignments: AssignmentsMap
reservations: Reservation[]
dayNotes: Record<string, DayNote[]>
t: (key: string, params?: Record<string, any>) => string
locale: string
toast: ReturnType<typeof useToast>
/**
* Gates "Subscribe to calendar" only. The one-off ICS download above it stays
* open to every member: it is a file they already have the right to read,
* while the subscription mints a link that works without an account.
*/
canManageShare?: boolean
}
/**
* Every way a trip leaves TREK, in one dialog: the day plan as a PDF, the
* bookings as a calendar (a one-off .ics or a live subscription) and the map
* data as GPX in the three scopes a device actually wants.
*
* It replaces three separate toolbar buttons, two of which opened hover menus of
* their own. On a narrow sidebar that row simply ran out of width and the
* exports dropped off the edge; one button cannot.
*/
export function TripExportModal({
isOpen, onClose, tripId, trip, days, places, categories, assignments, reservations, dayNotes,
t, locale, toast, canManageShare = true,
}: TripExportModalProps) {
const [subscribeOpen, setSubscribeOpen] = useState(false)
// Which row is working, so the dialog can say so instead of looking inert
// while a 226 kB PDF builder is fetched and a document is rendered.
const [busy, setBusy] = useState<string | null>(null)
const fileBase = trip?.title || 'trip'
// Shared tail of every download: Firefox and Safari cancel the download when
// the object URL is revoked before they picked the blob up, hence the delay.
const saveBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100)
}
const exportPdf = async () => {
if (busy) return
setBusy('pdf')
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
notes.map(n => ({ ...n, day_id: Number(dayId) })),
)
try {
// Loaded on click: the PDF builder is ~226 kB and hangs off the days
// sidebar, so every trip used to pay for it whether or not anyone
// exported. A missing chunk lands in the catch and shows the same error
// the export already had.
const { downloadTripPDF } = await import('../PDF/TripPDF')
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
onClose()
} catch (e) {
console.error('PDF error:', e)
toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setBusy(null)
}
}
const downloadIcs = async () => {
if (busy) return
setBusy('ics')
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
if (!res.ok) throw new Error()
saveBlob(await res.blob(), `${fileBase}.ics`)
onClose()
} catch {
toast.error(t('planner.icsExportFailed'))
} finally {
setBusy(null)
}
}
const downloadGpx = async (key: string, query: string) => {
if (busy) return
setBusy(`gpx:${key}`)
try {
const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' })
// 404 here means the selection is empty, which is worth its own message:
// "nothing happened" and "the download broke" look identical otherwise.
if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return }
if (!res.ok) throw new Error()
saveBlob(await res.blob(), `${fileBase}.gpx`)
onClose()
} catch {
toast.error(t('dayplan.gpxFailed'))
} finally {
setBusy(null)
}
}
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size="lg"
title={
<span className="flex items-center gap-2.5">
<span className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-accent-subtle text-accent-on">
<Download size={16} strokeWidth={2} />
</span>
{t('dayplan.export')}
</span>
}
>
<p className="-mt-1 mb-5 text-caption text-content-muted">{t('dayplan.exportIntro')}</p>
<Section label={t('dayplan.exportDocument')}>
<ExportRow
icon={FileText}
title={t('dayplan.pdf')}
sub={t('dayplan.pdfTooltip')}
busy={busy === 'pdf'}
disabled={busy != null}
onClick={exportPdf}
/>
</Section>
<Section label={t('dayplan.exportCalendar')}>
<ExportRow
icon={CalendarDays}
title={t('mobileTrip.icsDownload')}
sub={`${fileBase}.ics`}
busy={busy === 'ics'}
disabled={busy != null}
onClick={downloadIcs}
/>
{canManageShare && (
<ExportRow
icon={CalendarPlus}
title={t('mobileTrip.icsSubscribe')}
sub={t('mobileTrip.icsSubscribeSub')}
disabled={busy != null}
onClick={() => setSubscribeOpen(true)}
/>
)}
</Section>
{/* GPX — the counterpart to the GPX import in the places sidebar. The
format name carries more than a translated heading would, so it is
spelled out beside the plain-language one. */}
<Section label={`${t('dayplan.exportMaps')} · GPX`}>
{GPX_SCOPES.map(scope => (
<ExportRow
key={scope.key}
icon={scope.icon}
title={t(scope.labelKey)}
busy={busy === `gpx:${scope.key}`}
disabled={busy != null}
onClick={() => downloadGpx(scope.key, scope.query)}
/>
))}
</Section>
</Modal>
{subscribeOpen && canManageShare && (
<IcsSubscribeModal
endpoint={`/api/trips/${tripId}/feed`}
title={t('mobileTrip.icsSubscribe')}
description={t('mobileTrip.icsSubscribeSub')}
onClose={() => setSubscribeOpen(false)}
/>
)}
</>
)
}
function Section({ label, children }: { label: string; children: ReactNode }) {
return (
<section className="mb-4 last:mb-0">
<h3 className="mb-1.5 px-1 text-caption font-bold uppercase tracking-[0.07em] text-content-faint">{label}</h3>
<div className="divide-y divide-edge-faint overflow-hidden rounded-xl border border-edge-faint bg-surface">
{children}
</div>
</section>
)
}
function ExportRow({ icon: Icon, title, sub, busy = false, disabled = false, onClick }: {
icon: LucideIcon
title: string
sub?: string
busy?: boolean
disabled?: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="group flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
>
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] bg-surface-tertiary text-content-secondary transition-colors group-enabled:group-hover:bg-accent-subtle group-enabled:group-hover:text-accent-on">
{busy ? <Loader2 size={16} strokeWidth={2} className="animate-spin" /> : <Icon size={16} strokeWidth={1.9} />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body font-semibold text-content">{title}</span>
{sub && <span className="block truncate text-caption text-content-muted">{sub}</span>}
</span>
<ChevronRight
size={15}
strokeWidth={2}
className="flex-none text-content-faint transition-transform group-enabled:group-hover:translate-x-0.5"
/>
</button>
)
}
@@ -5,6 +5,7 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client'
import { collectionsApi } from '../../api/collections'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
@@ -157,8 +158,32 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
const [saveToListOpen, setSaveToListOpen] = useState(false)
const [markVisitedBusy, setMarkVisitedBusy] = useState(false)
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
/**
* "I have been to these" for the selection, applied wherever the places are
* saved in the library (#1469). The server does the matching, so a place saved
* under a different name in a list is still found.
*/
const markSelectionVisited = useCallback(async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0 || markVisitedBusy) return
setMarkVisitedBusy(true)
try {
const { updated, places: matchedPlaces } = await collectionsApi.setStatusFromTrip(props.tripId, ids, 'visited')
if (updated === 0) toast.info(t('collections.markVisitedNone'))
else toast.success(t('collections.markedVisitedTrip', { count: matchedPlaces ?? 0 }))
exitSelectMode()
} catch {
toast.error(t('common.error'))
} finally {
setMarkVisitedBusy(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, markVisitedBusy, props.tripId, t])
// Auto-exit when all selected places have been removed from the store (e.g. after bulk delete)
useEffect(() => {
if (!selectMode || selectedIds.size === 0) return
@@ -275,6 +300,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
categoryPickerOpen, setCategoryPickerOpen,
saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId,
markSelectionVisited, markVisitedBusy,
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
@@ -1,22 +1,35 @@
// FE-COMP-LLM-001 to FE-COMP-LLM-013
// FE-COMP-LLM-001 to FE-COMP-LLM-016
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildSettings } from '../../../tests/helpers/factories';
import { buildAdmin, buildSettings, buildUser } from '../../../tests/helpers/factories';
import { useSettingsStore } from '../../store/settingsStore';
import { useAuthStore } from '../../store/authStore';
import type { Settings } from '../../types';
import { ToastContainer } from '../shared/Toast';
import LlmConnectionSection from './LlmConnectionSection';
let loadSettings = vi.fn().mockResolvedValue(undefined);
function seedLlm(over: Partial<Settings> = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) {
loadSettings = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en', ...over }),
isLoaded: true,
updateSettings,
loadSettings,
});
return updateSettings;
}
function seedRole(role: 'user' | 'admin') {
seedStore(useAuthStore, {
user: role === 'admin' ? buildAdmin() : buildUser(),
isAuthenticated: true,
isLoading: false,
});
}
function renderSection() {
return render(
<>
@@ -39,31 +52,30 @@ async function pickProvider(user: ReturnType<typeof userEvent.setup>, current: R
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedRole('user');
seedLlm();
});
describe('LlmConnectionSection', () => {
it('FE-COMP-LLM-001: defaults to the local provider with a base URL and without a key field', () => {
it('FE-COMP-LLM-001: defaults to OpenAI with a key field and no endpoint of its own', () => {
renderSection();
expect(screen.getByText('AI parsing')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument();
expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('API key')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
expect(screen.getByPlaceholderText('API key')).toBeInTheDocument();
// The endpoint is instance configuration since #1772, so it has no field here.
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
expect(
screen.getByText('A self-hosted (Ollama) endpoint is set up once for the whole instance in the admin settings. You can still use your own OpenAI or Anthropic key here.'),
).toBeInTheDocument();
});
it('FE-COMP-LLM-002: hydrates provider, model, base URL and the multimodal toggle', () => {
seedLlm({
llm_provider: 'openai',
llm_model: 'gpt-4o-mini',
llm_base_url: 'https://api.openai.com/v1',
llm_multimodal: true,
});
it('FE-COMP-LLM-002: hydrates provider, model and the multimodal toggle', () => {
seedLlm({ llm_provider: 'openai', llm_model: 'gpt-4o-mini', llm_multimodal: true });
renderSection();
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
expect(screen.getByDisplayValue('gpt-4o-mini')).toBeInTheDocument();
expect(screen.getByDisplayValue('https://api.openai.com/v1')).toBeInTheDocument();
expect(multimodalToggle()).toHaveAttribute('aria-pressed', 'true');
});
@@ -74,7 +86,7 @@ describe('LlmConnectionSection', () => {
});
renderSection();
expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
expect(screen.queryByDisplayValue('claude-sonnet')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled();
});
@@ -84,7 +96,6 @@ describe('LlmConnectionSection', () => {
renderSection();
expect(screen.getByPlaceholderText('••••••••')).toHaveValue('');
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
});
it('FE-COMP-LLM-005: without a stored key the field falls back to the plain label placeholder', () => {
@@ -94,39 +105,38 @@ describe('LlmConnectionSection', () => {
expect(screen.getByPlaceholderText('API key')).toHaveValue('');
});
it('FE-COMP-LLM-006: picking Anthropic hides the base URL and reveals the key field', async () => {
it('FE-COMP-LLM-006: the key field stays through a provider change, since both are hosted', async () => {
const user = userEvent.setup();
renderSection();
await pickProvider(user, /Local \(Ollama\)/, 'Anthropic');
await pickProvider(user, /OpenAI/, 'Anthropic');
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
expect(screen.getByText('Stored encrypted. Leave blank to keep the current key.')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
});
it('FE-COMP-LLM-007: picking OpenAI keeps the base URL and adds the key field', async () => {
it('FE-COMP-LLM-007: the provider list is exactly the two hosted ones', async () => {
const user = userEvent.setup();
renderSection();
await pickProvider(user, /Local \(Ollama\)/, 'OpenAI');
await user.click(screen.getByRole('button', { name: /OpenAI/ }));
expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument();
expect(screen.getByPlaceholderText('API key')).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Anthropic' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Local \(Ollama\)/ })).not.toBeInTheDocument();
});
it('FE-COMP-LLM-008: saving trims the inputs and omits the key when none was typed', async () => {
const user = userEvent.setup();
const updateSettings = seedLlm({ llm_model: '', llm_base_url: '' });
const updateSettings = seedLlm({ llm_model: '' });
renderSection();
await user.type(screen.getByPlaceholderText('qwen3:8b'), ' qwen3:8b ');
await user.type(screen.getByPlaceholderText('http://localhost:11434'), ' http://ollama.local ');
await user.type(screen.getByPlaceholderText('qwen3:8b'), ' gpt-4o-mini ');
await user.click(screen.getByRole('button', { name: /^Save$/ }));
expect(updateSettings).toHaveBeenCalledWith({
llm_provider: 'local',
llm_model: 'qwen3:8b',
llm_base_url: 'http://ollama.local',
llm_provider: 'openai',
llm_model: 'gpt-4o-mini',
llm_base_url: '',
llm_multimodal: false,
});
await screen.findByText('AI settings saved');
@@ -144,14 +154,13 @@ describe('LlmConnectionSection', () => {
await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toHaveValue(''));
});
it('FE-COMP-LLM-010: a non-local provider never persists a base URL', async () => {
const user = userEvent.setup();
const updateSettings = seedLlm({ llm_provider: 'anthropic', llm_base_url: 'http://leftover' });
it('FE-COMP-LLM-010: a stored local provider falls back to OpenAI without saving anything', () => {
const updateSettings = seedLlm({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
renderSection();
await user.click(screen.getByRole('button', { name: /^Save$/ }));
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ llm_base_url: '' }));
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
expect(screen.queryByDisplayValue('http://192.168.1.5:11434')).not.toBeInTheDocument();
expect(updateSettings).not.toHaveBeenCalled();
});
it('FE-COMP-LLM-011: the multimodal toggle is part of the saved payload', async () => {
@@ -193,4 +202,45 @@ describe('LlmConnectionSection', () => {
release();
await waitFor(() => expect(saveBtn).toBeEnabled());
});
it('FE-COMP-LLM-014: saving clears a leftover base URL instead of resending it', async () => {
const user = userEvent.setup();
const updateSettings = seedLlm({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
renderSection();
await user.click(screen.getByRole('button', { name: /^Save$/ }));
expect(updateSettings).toHaveBeenCalledWith({
llm_provider: 'openai',
llm_model: 'nuextract',
llm_base_url: '',
llm_multimodal: false,
});
});
it('FE-COMP-LLM-015: a refused save pulls the stored settings back in', async () => {
const user = userEvent.setup();
seedLlm({ llm_provider: 'anthropic' }, vi.fn().mockRejectedValue(new Error('Admin access required')));
renderSection();
await user.click(screen.getByRole('button', { name: /^Save$/ }));
await screen.findByText('Could not save AI settings');
expect(loadSettings).toHaveBeenCalled();
});
// The point of #1772: an instance has one endpoint, so this surface is the same
// for the person who runs it. The admin sets it on the addon, not here.
it('FE-COMP-LLM-016: an admin sees the same two providers and no endpoint field', async () => {
const user = userEvent.setup();
seedRole('admin');
seedLlm({ llm_provider: 'local', llm_model: 'qwen3:8b', llm_base_url: 'http://localhost:11434' });
renderSection();
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /OpenAI/ }));
expect(screen.queryByRole('button', { name: /Local \(Ollama\)/ })).not.toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { Sparkles, Save } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -16,6 +16,14 @@ type Provider = NonNullable<Settings['llm_provider']>
* instance-wide model on the addon — the server resolves the admin config first.
* The API key is stored encrypted and never prefilled: a blank field keeps the
* stored key (mirrors the AirTrail connection layout).
*
* A free-form endpoint does not live here at all (#1772): the request goes out
* from the server, so only whoever runs the instance may name its target, and
* an instance has exactly one such target. It is configured once on the addon
* in the admin settings, including for the admin's own account. What is left
* here are the two hosted providers, which go to a fixed address with the
* user's own key. The server enforces this on both the read and the write path,
* so this is only the matching surface.
*/
export default function LlmConnectionSection(): React.ReactElement {
const { t } = useTranslation()
@@ -23,28 +31,36 @@ export default function LlmConnectionSection(): React.ReactElement {
const settings = useSettingsStore(s => s.settings)
const isLoaded = useSettingsStore(s => s.isLoaded)
const updateSettings = useSettingsStore(s => s.updateSettings)
const loadSettings = useSettingsStore(s => s.loadSettings)
const [provider, setProvider] = useState<Provider>('local')
const [provider, setProvider] = useState<Provider>('openai')
const [model, setModel] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [multimodal, setMultimodal] = useState(false)
const [hasStoredKey, setHasStoredKey] = useState(false)
const [saving, setSaving] = useState(false)
// Hydrate from the loaded settings. llm_api_key arrives masked, so we only use
// its presence to drive the placeholder — never the value itself.
// its presence to drive the placeholder, never the value itself. A stored
// 'local' from before #1772 shows as OpenAI (local state only, nothing is
// saved until Save is pressed) so the form never offers a value the server
// would refuse.
useEffect(() => {
if (!isLoaded) return
setProvider(settings.llm_provider || 'local')
const stored = settings.llm_provider || 'openai'
setProvider(stored === 'local' ? 'openai' : stored)
setModel(settings.llm_model || '')
setBaseUrl(settings.llm_base_url || '')
setMultimodal(settings.llm_multimodal === true)
setHasStoredKey(!!settings.llm_api_key)
}, [isLoaded, settings.llm_provider, settings.llm_model, settings.llm_base_url, settings.llm_multimodal, settings.llm_api_key])
}, [isLoaded, settings.llm_provider, settings.llm_model, settings.llm_multimodal, settings.llm_api_key])
const needsKey = provider !== 'local'
const showBaseUrl = provider === 'local' || provider === 'openai'
const providerOptions = useMemo(
() => [
{ value: 'openai', label: t('settings.aiParsing.providerOpenai') },
{ value: 'anthropic', label: t('settings.aiParsing.providerAnthropic') },
],
[t],
)
const handleSave = async () => {
setSaving(true)
@@ -52,7 +68,9 @@ export default function LlmConnectionSection(): React.ReactElement {
const payload: Partial<Settings> = {
llm_provider: provider,
llm_model: model.trim(),
llm_base_url: showBaseUrl ? baseUrl.trim() : '',
// Always cleared: the endpoint is instance configuration now, and this
// also drops a value left over from before #1772.
llm_base_url: '',
llm_multimodal: multimodal,
}
// Send the key only when the user typed a new one — a blank field means
@@ -64,6 +82,11 @@ export default function LlmConnectionSection(): React.ReactElement {
if (key) setHasStoredKey(true)
toast.success(t('settings.aiParsing.toast.saved'))
} catch {
// updateSettings patches the store before the request and keeps the patch
// when the request fails, so a refused save (the 403 from #1772, or any
// other error) would leave the form showing a value the server never
// stored. Pull the stored settings back in so what is on screen is real.
await loadSettings()
toast.error(t('settings.aiParsing.toast.saveError'))
} finally {
setSaving(false)
@@ -80,12 +103,9 @@ export default function LlmConnectionSection(): React.ReactElement {
<CustomSelect
value={provider}
onChange={v => setProvider(v as Provider)}
options={[
{ value: 'local', label: t('settings.aiParsing.providerLocal') },
{ value: 'openai', label: t('settings.aiParsing.providerOpenai') },
{ value: 'anthropic', label: t('settings.aiParsing.providerAnthropic') },
]}
options={providerOptions}
/>
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.localAdminOnly')}</p>
</div>
<div>
@@ -100,35 +120,20 @@ export default function LlmConnectionSection(): React.ReactElement {
/>
</div>
{showBaseUrl && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.baseUrl')}</label>
<input
type="url"
autoComplete="off"
value={baseUrl}
onChange={e => setBaseUrl(e.target.value)}
placeholder="http://localhost:11434"
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
/>
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.baseUrlHint')}</p>
</div>
)}
{needsKey && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.apiKey')}</label>
<input
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
autoComplete="off"
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
/>
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.apiKeyHint')}</p>
</div>
)}
{/* Both remaining providers are hosted and need a key, so this is no
longer conditional (#1772). */}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.apiKey')}</label>
<input
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
autoComplete="off"
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
/>
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.apiKeyHint')}</p>
</div>
<div>
<div className="flex items-center gap-3">
@@ -9,6 +9,7 @@ import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
import { useTranslation, isRtlLanguage } from '../../i18n/index.js';
import { runNoticeAction } from './noticeActions.js';
import { lockBodyScroll } from '../../utils/bodyScrollLock.js';
const ReactMarkdown = React.lazy(() =>
import('react-markdown').then(m => ({ default: m.default }))
@@ -504,15 +505,14 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
return () => document.removeEventListener('keydown', handler);
}, [visible, idx, notices.length, canPage, language]); // eslint-disable-line react-hooks/exhaustive-deps
// Body scroll lock
// Body scroll lock. Ref-counted and keyed on a boolean, not on the notice
// object: the old version cleared body.overflow on every run of this effect,
// which since #1809 would let the page scroll away behind an open sheet.
const locksScroll = visible && !!notice;
useEffect(() => {
if (visible && notice) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [visible, notice]);
if (!locksScroll) return;
return lockBodyScroll();
}, [locksScroll]);
// Reset center slot scroll to top on navigation (keyboard / pager buttons).
useEffect(() => {
@@ -1,4 +1,4 @@
// FE-COMP-VCYSET-001 to FE-COMP-VCYSET-014
// FE-COMP-VCYSET-001 to FE-COMP-VCYSET-016
// Covers the calendar editor / draft rows and the leave-year block of VacaySettings,
// which VacaySettings.test.tsx only reaches at the surface.
import React from 'react'
@@ -144,7 +144,7 @@ describe('VacaySettings calendars', () => {
expect(updateHolidayCalendar).toHaveBeenLastCalledWith(3, { region: 'NL' })
// The region list is only offered because the holiday data is county-split.
await pickLoadedOption('Select region (optional)', 'Nordrhein-Westfalen')
await pickLoadedOption('Select region (required)', 'Nordrhein-Westfalen')
expect(updateHolidayCalendar).toHaveBeenLastCalledWith(3, { region: 'DE-NW' })
})
@@ -159,7 +159,7 @@ describe('VacaySettings calendars', () => {
})
const { container } = render(<VacaySettings onClose={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'Select region (optional)' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Select region (required)' })).not.toBeInTheDocument()
const trash = trashButtons(container)[0]
fireEvent.mouseEnter(trash)
@@ -186,6 +186,9 @@ describe('VacaySettings calendars', () => {
fireEvent.change(screen.getByPlaceholderText('Label (optional)'), { target: { value: ' Feiertage ' } })
await pickLoadedOption('Select country', 'Germany')
// Germany has no regional holidays here, but the draft only unlocks once that
// answer is in (see FE-COMP-VCYSET-015).
await waitFor(() => expect(screen.getByRole('button', { name: 'Add' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith(expect.objectContaining({
@@ -203,10 +206,10 @@ describe('VacaySettings calendars', () => {
fireEvent.click(screen.getByRole('button', { name: 'Add calendar' }))
await pickLoadedOption('Select country', 'Germany')
await screen.findByRole('button', { name: 'Select region (optional)' })
await screen.findByRole('button', { name: 'Select region (required)' })
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled()
await pickLoadedOption('Select region (optional)', 'Bayern')
await pickLoadedOption('Select region (required)', 'Bayern')
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith(expect.objectContaining({ region: 'DE-BY' })))
@@ -232,7 +235,7 @@ describe('VacaySettings calendars', () => {
expect(screen.queryByRole('button', { name: 'United States' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Netherlands' }))
await pickLoadedOption('Select region (optional)', 'Northern Region')
await pickLoadedOption('Select region (required)', 'Northern Region')
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith(expect.objectContaining({
@@ -321,4 +324,71 @@ describe('VacaySettings calendars', () => {
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
})
})
it('FE-COMP-VCYSET-015: the draft stays blocked while the region list is still loading', async () => {
let release: (() => void) | undefined
server.use(http.get('/api/addons/vacay/holidays/:year/:country', async () => {
await new Promise<void>(resolve => { release = resolve })
return HttpResponse.json([
{ date: '2026-11-01', name: 'All Saints', localName: 'Allerheiligen', global: false, counties: ['DE-BY', 'DE-NW'] },
])
}))
useVacayStore.setState({ plan: buildPlan({ holidays_enabled: true }), addHolidayCalendar: vi.fn(async () => {}) })
render(<VacaySettings onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Add calendar' }))
await pickLoadedOption('Select country', 'Germany')
// An empty list means "no answer yet", not "this country needs no region" (#1813).
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Add' })).toHaveAttribute('aria-busy', 'true')
await waitFor(() => expect(release).toBeDefined())
release?.()
await screen.findByRole('button', { name: 'Select region (required)' })
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Add' })).toHaveAttribute('aria-busy', 'false')
await pickLoadedOption('Select region (required)', 'Bayern')
expect(screen.getByRole('button', { name: 'Add' })).toBeEnabled()
})
it('FE-COMP-VCYSET-016: switching a calendar country drops the old region list at once', async () => {
let releaseNl: (() => void) | undefined
server.use(http.get('/api/addons/vacay/holidays/:year/:country', async ({ params }) => {
if (params.country === 'NL') await new Promise<void>(resolve => { releaseNl = resolve })
return HttpResponse.json([{
date: '2026-11-01', name: 'All Saints', localName: 'Allerheiligen', global: false,
counties: params.country === 'NL' ? ['NL-NH'] : ['DE-BY', 'DE-NW'],
}])
}))
const updateHolidayCalendar = vi.fn(async (id: number, data: { region?: string }) => {
const plan = useVacayStore.getState().plan
if (!plan) return
useVacayStore.setState({
plan: { ...plan, holiday_calendars: (plan.holiday_calendars ?? []).map(c => (c.id === id ? { ...c, ...data } : c)) },
})
})
useVacayStore.setState({
plan: buildPlan({
holidays_enabled: true,
holiday_calendars: [{ id: 3, plan_id: 1, region: 'DE', label: null, color: '#fecaca', sort_order: 0 }],
}),
updateHolidayCalendar,
})
render(<VacaySettings onClose={vi.fn()} />)
await screen.findByRole('button', { name: 'Select region (required)' })
await pickLoadedOption('Germany', 'Netherlands')
expect(updateHolidayCalendar).toHaveBeenLastCalledWith(3, { region: 'NL' })
// The Dutch list is still on its way, so the German regions must be gone:
// otherwise DE-BY can be saved for the Netherlands (#1813).
await waitFor(() => expect(screen.queryByRole('button', { name: 'Select region (required)' })).not.toBeInTheDocument())
await waitFor(() => expect(releaseNl).toBeDefined())
releaseNl?.()
await screen.findByRole('button', { name: 'Select region (required)' })
})
})
+35 -20
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react'
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays, CalendarRange, GraduationCap } from 'lucide-react'
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Loader2, Plus, Trash2, CalendarDays, CalendarRange, GraduationCap } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { getIntlLanguage, useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -434,6 +434,30 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
)
}
/**
* Region options for a holiday-calendar country.
*
* The loaded list is tagged with the country it belongs to, so switching country
* drops the previous list in the same render instead of leaving it selectable while
* the next request is still running (#1813: picking a German region for the
* Netherlands was possible that way). `loadingRegions` tells "not answered yet"
* apart from "this country has no regions"; on the list alone both are empty.
*/
function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') {
const [loaded, setLoaded] = useState<{ country: string; options: { value: string; label: string }[] }>({ country: '', options: [] })
useEffect(() => {
if (!country) return
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
let stale = false
load(country).then(options => { if (!stale) setLoaded({ country, options }) })
return () => { stale = true }
}, [calendarType, country])
const ready = loaded.country === country
return { regions: ready ? loaded.options : [], loadingRegions: Boolean(country) && !ready }
}
// ── Existing calendar row (inline edit) ──────────────────────────────────────
function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
cal: VacayHolidayCalendar
@@ -446,21 +470,15 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
const { t } = useTranslation()
const [localColor, setLocalColor] = useState(cal.color)
const [localLabel, setLocalLabel] = useState(cal.label || '')
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
const [baseRegion] = cal.region.split('|')
const selectedCountry = baseRegion.split('-')[0]
const selectedRegion = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : ''
const { regions } = useRegionOptions(selectedCountry, calendarType)
useEffect(() => { setLocalColor(cal.color) }, [cal.color])
useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
useEffect(() => {
if (!selectedCountry) { setRegions([]); return }
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
load(selectedCountry).then(setRegions)
}, [calendarType, selectedCountry])
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
const [showColorPicker, setShowColorPicker] = useState(false)
@@ -533,21 +551,16 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
const [region, setRegion] = useState('')
const [color, setColor] = useState(defaultColor)
const [label, setLabel] = useState('')
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
const [loadingRegions, setLoadingRegions] = useState(false)
const [baseRegion] = region.split('|')
const selectedCountry = baseRegion.split('-')[0] || ''
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : ''
const { regions, loadingRegions } = useRegionOptions(selectedCountry, calendarType)
useEffect(() => {
if (!selectedCountry) { setRegions([]); return }
setLoadingRegions(true)
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
load(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false))
}, [calendarType, selectedCountry])
const canAdd = selectedCountry && (regions.length === 0 || selectedRegion !== '')
// Adding is blocked while the region list is on its way: an empty list would
// otherwise pass as "this country needs no region" and create a calendar that
// draws nothing (#1813). The button shows a spinner so the wait is visible.
const canAdd = Boolean(selectedCountry) && !loadingRegions && (regions.length === 0 || selectedRegion !== '')
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
const [showColorPicker, setShowColorPicker] = useState(false)
@@ -579,7 +592,7 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
/>
<CustomSelect
value={selectedCountry}
onChange={v => { setRegion(String(v)); setRegions([]) }}
onChange={v => setRegion(String(v))}
options={countries}
placeholder={t('vacay.selectCountry')}
searchable
@@ -596,9 +609,11 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
<div className="flex gap-1.5 pt-0.5">
<button
disabled={!canAdd}
aria-busy={loadingRegions}
onClick={() => onAdd({ region: region || selectedCountry, color, label: label.trim() || null })}
className="flex-1 text-xs px-2 py-1.5 rounded-md font-medium transition-colors disabled:opacity-40 bg-content text-surface-card"
className="flex-1 flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-md font-medium transition-colors disabled:opacity-40 bg-content text-surface-card"
>
{loadingRegions && <Loader2 size={12} className="animate-spin" />}
{t('vacay.add')}
</button>
<button
@@ -1,12 +1,14 @@
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import Modal from './Modal';
import { lockBodyScroll, resetBodyScrollLock } from '../../utils/bodyScrollLock';
describe('Modal', () => {
const onClose = vi.fn();
beforeEach(() => {
onClose.mockClear();
resetBodyScrollLock();
document.body.style.overflow = '';
});
@@ -80,4 +82,18 @@ describe('Modal', () => {
render(<Modal isOpen={true} onClose={onClose} />);
expect(document.body.style.overflow).toBe('hidden');
});
// #1809: the document is the scroller on a phone, so closing this modal must
// not unlock the page while another overlay is still holding the lock.
it('FE-COMP-MODAL-012: unmounting keeps a lock another overlay still holds', () => {
const otherOverlay = lockBodyScroll();
const { unmount } = render(<Modal isOpen={true} onClose={onClose} />);
expect(document.body.style.overflow).toBe('hidden');
unmount();
expect(document.body.style.overflow).toBe('hidden');
otherOverlay();
expect(document.body.style.overflow).toBe('');
});
});
+12 -8
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useCallback, useRef } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { lockBodyScroll } from '../../utils/bodyScrollLock'
const sizeClasses: Record<string, string> = {
sm: 'max-w-sm',
@@ -39,16 +40,19 @@ export default function Modal({
}, [onClose])
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEsc)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEsc)
document.body.style.overflow = ''
}
if (!isOpen) return
document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [isOpen, handleEsc])
// Separate from the key listener so a new onClose identity does not release
// and re-take the lock on every render. The shared lock is ref-counted: this
// modal must not clear a lock another overlay is still holding (#1809).
useEffect(() => {
if (!isOpen) return
return lockBodyScroll()
}, [isOpen])
const mouseDownTarget = useRef<EventTarget | null>(null)
if (!isOpen) return null
+24
View File
@@ -5,6 +5,30 @@
html { height: 100%; overflow: hidden; background-color: var(--bg-primary); }
body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; }
/* Phone (<768px): the document is the scroller, not body (#1809). iOS Safari
only minimises its address bar in reaction to the root scroller moving, so a
locked html keeps the bar at full height forever. `visible` on both, not
`auto`: only then does body's overflow propagate to the viewport, which is
what lets the shared body scroll lock stop the page behind an overlay.
overscroll-behavior has to sit on html, because the value on body is never
propagated to the viewport, and without it a pull at the top of the page
reloads the installed PWA. Desktop/tablet keep the rules above. */
@media (max-width: 767px) {
html { overflow: visible; overscroll-behavior: none; }
body { height: auto; overflow: visible; }
/* Transparent so the shell's own background layer, which sits behind the page
on a negative z-index, is not painted over: a negative layer goes above the
canvas but below the background of ordinary blocks, and body is one. The
alternative, lifting the layer into the positioned range, would have turned
the content wrapper into a stacking context and trapped every fixed overlay
inside it (the vacay FAB ended up under the dock that way).
`html body` and not `body`: a media query adds no specificity, and the
themed `body` rule further down this file would otherwise win on source
order alone and paint over the layer again. */
html body { background-color: transparent; }
}
/* Journey desktop feed: hide scrollbar (right column is a sticky map, a
visible scrollbar on the left breaks the polarsteps-style reading feel). */
+24 -5
View File
@@ -18,9 +18,13 @@ interface MobileShellProps {
* mobile toast presenter. From 768px up it renders the exact legacy wrapper
* unchanged. Both branches share the div > div > children shape so pages keep
* their state when the viewport crosses the breakpoint (rotation,
* split-screen) instead of remounting. The content area keeps the exact
* scroll semantics of the legacy ProtectedRoute wrapper, so not-yet-migrated
* pages render unchanged inside.
* split-screen) instead of remounting.
*
* On the phone the shell deliberately owns no scroll container (#1809): the
* document is the scroller, because that is the only movement iOS Safari
* minimises its address bar for. Screens that want a full-viewport layout
* (map screens, the trip planner) set their own height instead of borrowing
* one from here.
*/
export default function MobileShell({ isPhone, children }: MobileShellProps) {
// The trip planner (/trips/:id) is a full-screen takeover that ships its own
@@ -39,8 +43,23 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
}
return (
<div className="m-root flex h-dvh min-h-dvh flex-col bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] text-m-ink">
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
// min-h-svh, not min-h-dvh: dvh grows while Safari's toolbar retracts, so a
// dvh page length would chase the animation it just triggered.
<div className="m-root flex min-h-svh flex-col text-m-ink">
{/* Background and screen gradient live on their own viewport-sized layer.
The shell root is as tall as the document now, and --m-scr is a radial
gradient measured against its element box, so on it the light cone would
be stretched over the whole scroll length.
It stays on a NEGATIVE z-index, and the content wrapper stays unpositioned
on purpose. Lifting the layer into the positioned range means the content
has to be positioned too, which makes it a stacking context and clamps
every fixed overlay a screen renders inside it: the vacay view/edit FAB
(z-50) ended up underneath the dock (z-40) that way. For the negative
layer to be visible, body must not paint over it, which index.css handles
for this breakpoint. */}
<div aria-hidden className="pointer-events-none fixed inset-0 -z-10 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]" />
<div className="flex-1">{children}</div>
{!inTripPlanner && <ErrorBoundary boundaryId="chrome:m-bottom-nav" fallback={null}><MBottomNav /></ErrorBoundary>}
<ErrorBoundary boundaryId="chrome:m-toast" fallback={null}><MToastHost /></ErrorBoundary>
<div id="m-sheet-root" />
@@ -0,0 +1,100 @@
// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-007
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
const mockNavigate = vi.fn();
vi.mock('react-router', async () => {
const actual = await vi.importActual<typeof import('react-router')>('react-router');
return { ...actual, useNavigate: () => mockNavigate };
});
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import MBottomNav from './MBottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
const withCollections = () =>
seedStore(useAddonStore, {
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true }],
});
beforeEach(() => {
resetAllStores();
mockNavigate.mockClear();
sessionStorage.clear();
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
});
describe('MBottomNav', () => {
it('FE-COMP-MBOTTOMNAV-001: the dock "+" creates a trip on an unclaimed route', async () => {
const user = userEvent.setup();
render(<MBottomNav />, { initialEntries: ['/dashboard'] });
await user.click(screen.getByRole('button', { name: 'New Trip' }));
expect(mockNavigate).toHaveBeenCalledWith('/dashboard?create=1');
});
it('FE-COMP-MBOTTOMNAV-002: on the collections overview the "+" adds a place', async () => {
const user = userEvent.setup();
withCollections();
render(<MBottomNav />, { initialEntries: ['/collections'] });
await user.click(screen.getByRole('button', { name: 'Add a place' }));
expect(mockNavigate).toHaveBeenCalledWith('/collections?create=place');
});
// #1930: picking a list moves the route to /collections/:id, which the exact
// match missed — the "+" then created a trip instead of adding a place.
it('FE-COMP-MBOTTOMNAV-003: inside a collection the "+" adds a place, not a trip', async () => {
const user = userEvent.setup();
withCollections();
render(<MBottomNav />, { initialEntries: ['/collections/7'] });
await user.click(screen.getByRole('button', { name: 'Add a place' }));
expect(mockNavigate).toHaveBeenCalledWith('/collections/7?create=place');
expect(mockNavigate).not.toHaveBeenCalledWith('/dashboard?create=1');
});
it('FE-COMP-MBOTTOMNAV-004: the handoff keeps the list the user is looking at', async () => {
const user = userEvent.setup();
withCollections();
render(<MBottomNav />, { initialEntries: ['/collections/123'] });
await user.click(screen.getByRole('button', { name: 'Add a place' }));
expect(mockNavigate).toHaveBeenCalledWith('/collections/123?create=place');
});
it('FE-COMP-MBOTTOMNAV-005: on the journey list the "+" starts a journey', async () => {
const user = userEvent.setup();
seedStore(useAddonStore, {
addons: [{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true }],
});
render(<MBottomNav />, { initialEntries: ['/journey'] });
await user.click(screen.getByRole('button', { name: 'New Journey' }));
expect(mockNavigate).toHaveBeenCalledWith('/journey?create=1');
});
it('FE-COMP-MBOTTOMNAV-006: inside a journey the "+" adds an entry', async () => {
const user = userEvent.setup();
seedStore(useAddonStore, {
addons: [{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true }],
});
render(<MBottomNav />, { initialEntries: ['/journey/5'] });
await user.click(screen.getByRole('button', { name: 'Add Entry' }));
expect(mockNavigate).toHaveBeenCalledWith('/journey/5?create=entry');
});
it('FE-COMP-MBOTTOMNAV-007: /vacay yields the centre slot to the screen (#1811)', () => {
render(<MBottomNav />, { initialEntries: ['/vacay'] });
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
});
});
+20 -3
View File
@@ -16,7 +16,8 @@ interface NavItem { to: string; label: string; icon: LucideIcon }
// into yet), inside a journey it adds an entry, on the atlas it opens the
// country search, on collections it adds a place to the active list —
// everywhere else it creates a new trip. Pages pick the intent up from the
// query params.
// query params. The result is unused on /vacay: that screen draws its own centre
// FAB, so the dock yields the slot instead (see screenFabSlot below, #1811).
function useCreateAction(): { label: string; run: () => void } {
const navigate = useNavigate()
const { t } = useTranslation()
@@ -25,6 +26,7 @@ function useCreateAction(): { label: string; run: () => void } {
const onJourneyList = useMatch('/journey')
const onAtlas = useMatch('/atlas')
const onCollections = useMatch('/collections')
const inCollection = useMatch('/collections/:id')
if (inTrip) {
// The "+" is context-aware per active tab: Bookings → reservation,
@@ -46,8 +48,14 @@ function useCreateAction(): { label: string; run: () => void } {
if (onAtlas) {
return { label: t('atlas.searchCountry'), run: () => navigate('/atlas?search=1') }
}
if (onCollections) {
return { label: t('collections.addPlace'), run: () => navigate('/collections?create=place') }
if (onCollections || inCollection) {
// Picking a list moves the route to /collections/:id, so the exact match
// alone dropped the "+" through to creating a trip — the one state the
// screen is normally used in (#1930). The handoff keeps the id, otherwise
// adding would land on "All saved" and the sheet would ask for the list the
// user is already looking at.
const path = inCollection ? `/collections/${inCollection.params.id}` : '/collections'
return { label: t('collections.addPlace'), run: () => navigate(`${path}?create=place`) }
}
return { label: t('dashboard.newTrip'), run: () => navigate('/dashboard?create=1') }
}
@@ -87,6 +95,11 @@ export default function MBottomNav() {
// action (settings/admin, demo Z. 1372/1429).
const logoSlot = location.pathname.startsWith('/settings') || location.pathname.startsWith('/admin')
const searchFab = location.pathname.startsWith('/atlas')
// /vacay owns the centre slot itself: MVacay draws its year/edit toggle into
// exactly this 56px circle. The dock keeps the geometry but stays empty there,
// so the generic "+" can never sit underneath as a second, different action:
// not while the lazy screen chunk loads, and not while its data loads (#1811).
const screenFabSlot = location.pathname.startsWith('/vacay')
// Split so the raised centre slot sits dead centre; the More slot always
// closes the right group.
@@ -154,6 +167,10 @@ export default function MBottomNav() {
<img src="/icons/icon-dark.svg" alt="" className="block h-6 w-6 opacity-75 dark:hidden" />
<img src="/icons/icon-white.svg" alt="" className="hidden h-6 w-6 opacity-75 dark:block" />
</span>
) : screenFabSlot ? (
// Same box as MFab (56px, flex-none, mx-2) so both tab groups keep
// sitting symmetrically around the centre in every dock configuration.
<span aria-hidden="true" className="mx-2 h-14 w-14 flex-none" />
) : (
<MFab onClick={create.run} ariaLabel={create.label} className="mx-2">
{searchFab ? <Search size={24} strokeWidth={2.4} /> : <Plus size={26} strokeWidth={2.4} />}
+2 -20
View File
@@ -1,5 +1,6 @@
import React, { ReactNode, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { lockBodyScroll } from '../../utils/bodyScrollLock'
export type MSheetVariant = 'card' | 'bottom' | 'drawer'
export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque'
@@ -54,24 +55,6 @@ function sheetRoot(): HTMLElement {
return document.getElementById('m-sheet-root') ?? document.body
}
// Body scroll lock shared across stacked sheets: only the first lock saves the
// original overflow and only the last unlock restores it.
let scrollLocks = 0
let savedBodyOverflow = ''
function lockBodyScroll() {
if (scrollLocks === 0) {
savedBodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
}
scrollLocks++
}
function unlockBodyScroll() {
scrollLocks = Math.max(0, scrollLocks - 1)
if (scrollLocks === 0) document.body.style.overflow = savedBodyOverflow
}
/**
* The one sheet primitive of the mobile design system. Handles portal,
* scrim, ESC/backdrop dismissal, body scroll lock, focus trapping, 280ms
@@ -118,8 +101,7 @@ export default function MSheet({
useEffect(() => {
if (!open) return
lockBodyScroll()
return unlockBodyScroll
return lockBodyScroll()
}, [open])
// Move focus into the dialog on open, hand it back on close.
+3 -1
View File
@@ -105,7 +105,9 @@ export default function MAdmin() {
}
return (
<div className="h-full overflow-y-auto px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[var(--m-safe-top,12px)]">
// Flow screen: scrolls with the document (#1809), so no height and no
// scroll container of its own.
<div className="px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[var(--m-safe-top,12px)]">
{/* Header: back · section switcher · create user */}
<div className="mb-3 flex items-center gap-2">
<button
+4 -2
View File
@@ -89,7 +89,7 @@ export default function MAtlas() {
if (loading) {
return (
<div className="relative h-full">
<div className="relative h-dvh">
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-ink" />
</div>
@@ -98,7 +98,9 @@ export default function MAtlas() {
}
return (
<div className="relative h-full overflow-hidden">
// h-dvh, not h-full: the shell stopped providing a definite height (#1809)
// and a map on a percentage of an auto-height parent collapses to zero.
<div className="relative h-dvh overflow-hidden">
<div ref={mapRef} className="absolute inset-0 z-[1] bg-[color:var(--m-mapb)] [&_.leaflet-control-zoom]:hidden" />
{/* Region hover tooltip — positioned imperatively by useAtlas. */}
<div
@@ -1,7 +1,9 @@
// FE-COMP-MATLASPOPUP-001 to FE-COMP-MATLASPOPUP-004
// FE-COMP-MATLASPOPUP-001 to FE-COMP-MATLASPOPUP-006
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { render, screen, fireEvent, waitFor } from '../../../../tests/helpers/render'
import { server } from '../../../../tests/helpers/msw/server'
import MAtlasCountryPopup from './MAtlasCountryPopup'
import type { AtlasController } from './atlasController'
@@ -27,6 +29,10 @@ function makeAtlas(overrides: Record<string, unknown> = {}): AtlasController {
} as unknown as AtlasController
}
afterEach(() => {
delete window.__addToast
})
describe('MAtlasCountryPopup', () => {
it('FE-COMP-MATLASPOPUP-001: offers the wishlist removal for a country on the bucket list', () => {
const atlas = makeAtlas({ bucketList: [bucketItem(42, 'Paris', 'FR')] })
@@ -73,4 +79,53 @@ describe('MAtlasCountryPopup', () => {
expect(screen.getByText('atlas.markVisited')).toBeInTheDocument()
expect(screen.queryByText('atlas.removeFromBucket')).not.toBeInTheDocument()
})
it('FE-COMP-MATLASPOPUP-005: adding a country that is already wishlisted for that date posts nothing (#1898)', async () => {
const post = vi.fn()
server.use(http.post('/api/addons/atlas/bucket-list', () => { post(); return HttpResponse.json({ item: { id: 9 } }) }))
const addToast = vi.fn()
window.__addToast = addToast as unknown as typeof window.__addToast
const setBucketList = vi.fn()
const setConfirmAction = vi.fn()
const atlas = makeAtlas({
confirmAction: { code: 'FR', name: 'France', type: 'bucket' },
bucketList: [bucketItem(42, 'France', 'FR')],
setBucketList,
setConfirmAction,
})
render(<MAtlasCountryPopup atlas={atlas} />)
fireEvent.click(screen.getByText('atlas.addToBucket'))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('atlas.bucketDuplicate', 'error', undefined))
expect(post).not.toHaveBeenCalled()
expect(setBucketList).not.toHaveBeenCalled()
// The sheet stays open so a target month can be picked instead.
expect(setConfirmAction).not.toHaveBeenCalled()
})
it('FE-COMP-MATLASPOPUP-006: the same country for another target date still goes out (#1898)', async () => {
let body: unknown = null
server.use(http.post('/api/addons/atlas/bucket-list', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ item: { id: 9 } })
}))
const setBucketList = vi.fn()
const setConfirmAction = vi.fn()
const atlas = makeAtlas({
confirmAction: { code: 'FR', name: 'France', type: 'bucket' },
bucketList: [bucketItem(42, 'France', 'FR')],
setBucketList,
setConfirmAction,
})
render(<MAtlasCountryPopup atlas={atlas} />)
const monthInput = document.querySelector('input[type="month"]') as HTMLInputElement
fireEvent.change(monthInput, { target: { value: '2027-05' } })
fireEvent.click(screen.getByText('atlas.addToBucket'))
await waitFor(() => expect(setConfirmAction).toHaveBeenCalledWith(null))
expect(body).toEqual({ name: 'France', country_code: 'FR', target_date: '2027-05' })
expect(setBucketList).toHaveBeenCalled()
})
})
@@ -3,7 +3,7 @@ import { ChevronRight, MapPin, Star, Trash2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import apiClient from '../../../api/client'
import { continentForCountry } from '@trek/shared'
import { withCountryMarkedVisited } from '../../../pages/atlas/atlasModel'
import { findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited } from '../../../pages/atlas/atlasModel'
import { getApiErrorMessage } from '../../../types'
import { useToast } from '../../../components/shared/Toast'
import MSheet from '../../components/MSheet'
@@ -145,21 +145,33 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) {
const addBucket = async (): Promise<void> => {
if (!confirmAction) return
const targetDate = bucketDate || null
// #1898: one entry per target date. The sheet stays open on a duplicate so
// another month can be picked right away.
if (findBucketDuplicate(bucketList, { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate, lat: null, lng: null })) {
toast.error(t('atlas.bucketDuplicate'))
return
}
try {
const r = await apiClient.post('/addons/atlas/bucket-list', {
name: confirmAction.name,
country_code: confirmAction.code,
target_date: bucketDate || null,
target_date: targetDate,
})
setBucketList((prev) => [r.data.item, ...prev])
} catch (err) {
if (isBucketDuplicateError(err)) {
toast.error(t('atlas.bucketDuplicate'))
return
}
toast.error(getApiErrorMessage(err, t('common.error')))
}
setConfirmAction(null)
}
// A country can sit on the bucket list more than once (one entry per place),
// so taking it off the wishlist means dropping every entry for that code.
// A country can sit on the bucket list more than once (one entry per place and
// target date), so taking it off the wishlist means dropping every entry for
// that code.
const removeBucket = async (): Promise<void> => {
if (!confirmAction) return
const wishlistItems = bucketList.filter((b) => b.country_code === confirmAction.code)
@@ -42,6 +42,7 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
const [searching, setSearching] = useState(false)
const [picked, setPicked] = useState<MapsPlace | null>(null)
const [name, setName] = useState('')
const [address, setAddress] = useState('')
const [categoryId, setCategoryId] = useState<number | null>(null)
const [status, setStatus] = useState<CollectionStatus>('idea')
const [description, setDescription] = useState('')
@@ -50,7 +51,7 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
useEffect(() => {
if (open) return
setQuery(''); setResults([]); setPicked(null); setName(''); setCategoryId(null); setStatus('idea'); setDescription('')
setQuery(''); setResults([]); setPicked(null); setName(''); setAddress(''); setCategoryId(null); setStatus('idea'); setDescription('')
setTargetId(null)
}, [open])
@@ -80,6 +81,7 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
const pick = (r: MapsPlace) => {
setPicked(r)
setName(str(r.name) ?? '')
setAddress(str(r.address) ?? '')
setResults([])
setQuery(str(r.name) ?? query)
}
@@ -92,7 +94,7 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
const res = await collectionsApi.savePlace({
collection_id: targetId,
name: cleanName,
address: (picked && str(picked.address)) ?? null,
address: address.trim() || null,
lat: (picked && num(picked.lat)) ?? null,
lng: (picked && num(picked.lng)) ?? null,
google_place_id: (picked && str(picked.google_place_id)) ?? null,
@@ -118,8 +120,6 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
}
}
const address = picked ? str(picked.address) : undefined
return (
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={t('collections.addPlace')}>
<SheetHeader title={t('collections.addPlace')} onClose={onClose} closeLabel={t('common.close')} />
@@ -197,11 +197,11 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
<Eyebrow className="mb-[6px] mt-4">{t('common.name').toUpperCase()}</Eyebrow>
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('common.name')} className={INPUT_CLS} />
{address && (
<div className="mt-[6px] flex items-center gap-[5px] font-geist text-[0.6875rem] text-m-muted">
<MapPin size={11} strokeWidth={2.2} className="flex-none" /> <span className="truncate">{address}</span>
</div>
)}
{/* Address (#1870): a picked hit fills it, but it stays editable so a
hand-typed place can carry one too (the desktop dialog always could). */}
<Eyebrow className="mb-[6px] mt-[14px]">{t('places.formAddress').toUpperCase()}</Eyebrow>
<input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={INPUT_CLS} />
<Eyebrow className="mb-[6px] mt-[14px]">{t('collections.category').toUpperCase()}</Eyebrow>
<MCollCategoryPicker categories={categories} value={categoryId} onChange={setCategoryId} t={t} />
@@ -35,7 +35,7 @@ interface MCollPlaceSheetProps {
labels: CollectionLabel[]
onClose: () => void
onSetStatus: (status: CollectionStatus) => void
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null }) => Promise<void>
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; address?: string | null }) => Promise<void>
onUploadImage?: (file: File) => Promise<void>
onCopyToTrip: () => void
onRemove: () => void
@@ -62,6 +62,7 @@ export default function MCollPlaceSheet({
const [editing, setEditing] = useState(false)
const [name, setName] = useState('')
const [address, setAddress] = useState('')
const [categoryId, setCategoryId] = useState<number | null>(null)
const [description, setDescription] = useState('')
const [links, setLinks] = useState<CollectionLink[]>([])
@@ -77,6 +78,7 @@ export default function MCollPlaceSheet({
seededId.current = held.id
setEditing(false)
setName(held.name)
setAddress(held.address ?? '')
setCategoryId(held.category_id ?? null)
setDescription(held.description ?? '')
setLinks(held.links ?? [])
@@ -97,7 +99,7 @@ export default function MCollPlaceSheet({
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
setSaving(true)
try {
await onSave({ name: name.trim() || held.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds })
await onSave({ name: name.trim() || held.name, address: address.trim() || null, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds })
setEditing(false)
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
@@ -110,6 +112,7 @@ export default function MCollPlaceSheet({
if (!held) return
setEditing(false)
setName(held.name)
setAddress(held.address ?? '')
setCategoryId(held.category_id ?? null)
setDescription(held.description ?? '')
setLinks(held.links ?? [])
@@ -245,6 +248,9 @@ export default function MCollPlaceSheet({
<>
<Eyebrow className="mb-[6px] mt-4">{t('common.name').toUpperCase()}</Eyebrow>
<input value={name} onChange={e => setName(e.target.value)} className={INPUT_CLS} />
{/* Address (#1870): free text, same as the add sheet offers */}
<Eyebrow className="mb-[6px] mt-[14px]">{t('places.formAddress').toUpperCase()}</Eyebrow>
<input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={INPUT_CLS} />
<Eyebrow className="mb-[6px] mt-[14px]">{t('collections.category').toUpperCase()}</Eyebrow>
<MCollCategoryPicker categories={categories} value={categoryId} onChange={setCategoryId} t={t} />
{labels.length > 0 && (
@@ -27,8 +27,13 @@ import { CancelPill, PrimaryPill } from './MCollSheetKit'
const HEADER_CIRCLE =
'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]'
// min-w-0 is what lets the truncate on the chip labels engage: a flex item keeps
// min-width:auto by default, so the row grew past the panel instead of shrinking
// its chips. The labels are short enough to fit in every locale now, but a long
// label name in the filter chip, or a bumped-up text size, still has to ellipsise
// inside the panel rather than spill out of it.
const FILTER_CHIP =
'flex flex-1 items-center justify-center gap-[5px] rounded-full py-2 text-[0.75rem] font-semibold'
'flex min-w-0 flex-1 items-center justify-center gap-[5px] rounded-full py-2 text-[0.75rem] font-semibold'
const CHIP_IDLE = 'border border-[color:var(--m-rowbr)] bg-m-sheetop text-m-ink'
const CHIP_ON = 'border border-transparent bg-m-act text-m-actfg'
@@ -273,7 +278,7 @@ export default function MCollections() {
{canManageLabels && (
<button type="button" onClick={() => c.setShowLabelManager(true)} className={`${FILTER_CHIP} ${CHIP_IDLE}`}>
<Plus size={12} strokeWidth={2.2} className="flex-none" />
<span className="truncate">{t('collections.linkLabel')}</span>
<span className="truncate">{t('collections.labels.title')}</span>
</button>
)}
{showSelect && (
@@ -214,8 +214,8 @@ export default function MDashboard(): React.ReactElement {
<MGlassBar floating>
<button
type="button"
// Content scrolls in the shell's inner container, not the window.
onClick={e => { e.currentTarget.closest('.overflow-y-auto')?.scrollTo({ top: 0, behavior: 'smooth' }) }}
// The page itself is the scroller since #1809, no inner container to walk up to.
onClick={() => { window.scrollTo({ top: 0, behavior: 'smooth' }) }}
aria-label="TREK"
className="flex flex-none items-center gap-[7px]"
>
@@ -27,7 +27,10 @@ export default function MJourney() {
const rest = list.filter(j => j.id !== hero?.id)
return (
<div className="relative h-full">
// h-dvh, not h-full: the shell stopped providing a definite height (#1809).
// The feed below keeps its own scroller, so this screen does not move the
// document and Safari's address bar stays put here.
<div className="relative h-dvh">
{/* Floating header: back + create pill */}
<div className="fixed left-4 right-4 top-[var(--m-safe-top,12px)] z-30 flex items-center gap-[10px]">
<button
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronLeft, MapPin, Grid3x3, Upload, MoreHorizontal, Play, Image, Camera } from 'lucide-react'
import { ChevronLeft, MapPin, Grid3x3, Upload, MoreHorizontal, Play, Image, Camera, Download, EyeOff, Settings2 } from 'lucide-react'
import JourneyMap from '../../../components/Journey/JourneyMapAuto'
import type { JourneyMapAutoHandle } from '../../../components/Journey/JourneyMapAuto'
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
@@ -18,6 +18,7 @@ import { getApiErrorMessage } from '../../../types'
import MSheet from '../../components/MSheet'
import MDancingTrek from '../../components/MDancingTrek'
import MListRow from '../../components/MListRow'
import MToggle from '../../components/MToggle'
import MJourneyEntryCard from './MJourneyEntryCard'
import MJourneyEntrySheet from './MJourneyEntrySheet'
import MJourneySettingsSheet from './MJourneySettingsSheet'
@@ -29,7 +30,7 @@ import MJourneySettingsSheet from './MJourneySettingsSheet'
*/
export default function MJourneyDetail() {
const {
id, navigate, toast, t,
id, navigate, toast, t, locale,
current, loading,
canEditEntries, canEditJourney,
view, setView,
@@ -37,7 +38,7 @@ export default function MJourneyDetail() {
lightbox, setLightbox, deleteTarget, setDeleteTarget,
showInvite, setShowInvite,
showSettings, setShowSettings,
hideSkeletons,
hideSkeletons, setHideSkeletons,
sidebarMapItems, tracks,
loadJourney, updateEntry, deleteEntry, uploadPhotos,
} = useJourneyDetail()
@@ -129,6 +130,7 @@ export default function MJourneyDetail() {
const galleryFileRef = useRef<HTMLInputElement>(null)
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
const [showUploadMenu, setShowUploadMenu] = useState(false)
const [showActionMenu, setShowActionMenu] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
@@ -192,7 +194,7 @@ export default function MJourneyDetail() {
if (loading || !current) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex h-dvh items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-ink" />
</div>
)
@@ -201,8 +203,27 @@ export default function MJourneyDetail() {
const gallery = current.gallery || []
const dark = document.documentElement.classList.contains('dark')
// The book export and the suggestions switch used to be desktop-only (#1848).
// They live in the header overflow sheet here, together with the settings
// entry, so the header keeps a single "more" affordance.
const exportBook = async () => {
setShowActionMenu(false)
const pdf = await import('../../../components/PDF/JourneyBookPDF')
await pdf.downloadJourneyBookPDF(current, { t, locale })
}
const toggleSkeletons = async (next: boolean) => {
setHideSkeletons(next)
try {
await journeyApi.updatePreferences(current.id, { hide_skeletons: next })
} catch {
/* cosmetic preference — the local flip stands until the next load */
}
}
return (
<div className="relative h-full overflow-hidden">
// h-dvh, not h-full: the shell stopped providing a definite height (#1809)
// and a map on a percentage of an auto-height parent collapses to zero.
<div className="relative h-dvh overflow-hidden">
{/* Integrated map — always mounted, the gallery overlays it */}
<div className="absolute inset-0 z-0">
<JourneyMap
@@ -255,7 +276,7 @@ export default function MJourneyDetail() {
</div>
)}
{/* Header: back — segment — upload/settings */}
{/* Header: back — segment — upload / overflow menu */}
<div className="absolute left-4 right-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2">
<button
type="button"
@@ -303,16 +324,14 @@ export default function MJourneyDetail() {
)}
</button>
)}
{view === 'timeline' && canEditJourney && (
<button
type="button"
onClick={() => setShowSettings(true)}
aria-label={t('journey.settings.title')}
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
>
<MoreHorizontal size={17} strokeWidth={2} />
</button>
)}
<button
type="button"
onClick={() => setShowActionMenu(true)}
aria-label={t('files.menu')}
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
>
<MoreHorizontal size={17} strokeWidth={2} />
</button>
</span>
</div>
@@ -356,6 +375,25 @@ export default function MJourneyDetail() {
</div>
</MSheet>
{/* Journey actions: book export, suggestions switch, settings */}
<MSheet open={showActionMenu} onClose={() => setShowActionMenu(false)} variant="bottom" ariaLabel={t('files.menu')}>
<div className="flex flex-col gap-1 p-[10px]">
<MListRow icon={Download} label={t('journey.pdf.saveAsPdf')} onClick={exportBook} />
<div className="flex items-center gap-[11px] px-[10px] py-[11px]">
<EyeOff size={16} strokeWidth={2} className="flex-none text-m-muted" />
<span className="min-w-0 flex-1 truncate text-[0.84375rem] font-semibold">{t('journey.skeletons.hide')}</span>
<MToggle checked={hideSkeletons} onChange={toggleSkeletons} ariaLabel={t('journey.skeletons.hide')} />
</div>
{canEditJourney && (
<MListRow
icon={Settings2}
label={t('journey.settings.title')}
onClick={() => { setShowActionMenu(false); setShowSettings(true) }}
/>
)}
</div>
</MSheet>
{/* Entry sheet (new / edit / read-only) */}
{editingEntry && (
<MJourneyEntrySheet
@@ -363,18 +401,26 @@ export default function MJourneyDetail() {
galleryPhotos={gallery}
quickCapture={editingEntry.id === 0}
readOnly={!canEditEntries}
userId={useAuthStore.getState().user?.id || 0}
trips={current.trips}
onClose={() => setEditingEntry(null)}
onSave={async data => {
let entryId = editingEntry.id
if (editingEntry.id === 0) {
onSave={async (data, existingEntryId) => {
// existingEntryId is what the sheet already persisted in an earlier
// save attempt — without it a retry would create a second entry.
const currentEntryId = existingEntryId ?? editingEntry.id
let entryId = currentEntryId
if (currentEntryId === 0) {
const created = await useJourneyStore.getState().createEntry(current.id, data)
entryId = created.id
} else {
await updateEntry(editingEntry.id, data)
await updateEntry(currentEntryId, data)
}
return entryId
}}
onUploadPhotos={uploadPhotos}
onAddProviderPhotos={async (entryId, group) => {
await journeyApi.addProviderPhotos(entryId, group.provider, group.assetIds, undefined, group.passphrase, group.mediaTypes)
}}
onDelete={editingEntry.id > 0 && canEditEntries
? () => { const target = editingEntry; setEditingEntry(null); setDeleteTarget(target) }
: undefined}
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Camera, Plus, Image, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import { useTranslation } from '../../../i18n'
@@ -9,9 +9,11 @@ import { getApiErrorMessage } from '../../../types'
import { normalizeImageFiles } from '../../../utils/convertHeic'
import { getCurrentPositionOnce } from '../../../hooks/useGeolocation'
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto } from '../../../store/journeyStore'
import { photoUrl, geoOnceErrorKey } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
import { photoUrl, geoOnceErrorKey, isValidGeoPoint } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import JournalBody from '../../../components/Journey/JournalBody'
import { ProviderPicker, type ProviderPhotoGroup } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { journeyWeatherCategory, MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
const PRO_COLOR = '#2FA37A'
@@ -24,25 +26,31 @@ interface LocationResult {
lng: number
}
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
interface MJourneyEntrySheetProps {
entry: JourneyEntry
galleryPhotos: GalleryPhoto[]
quickCapture?: boolean
readOnly?: boolean
userId?: number
trips?: JourneyTrip[]
onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<number>
onSave: (data: Record<string, unknown>, existingEntryId?: number) => Promise<number>
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
onAddProviderPhotos?: (entryId: number, group: PendingProviderGroup) => Promise<void>
onDelete?: () => void
onDone: () => void
}
/**
* shJEntry — the journey entry sheet: title, photos (upload / from gallery),
* markdown story, pros & cons, date + time, location search, mood (4),
* weather (6) and tags. Read-only for viewer contributors.
* shJEntry — the journey entry sheet: title, photos (upload / from gallery /
* connected provider), markdown story, pros & cons, date + time, location
* search, mood (4), weather (6) and tags. Read-only for viewer contributors.
*/
export default function MJourneyEntrySheet({
entry, galleryPhotos, quickCapture = false, readOnly = false, onClose, onSave, onUploadPhotos, onDelete, onDone,
entry, galleryPhotos, quickCapture = false, readOnly = false, userId = 0, trips = [],
onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDelete, onDone,
}: MJourneyEntrySheetProps) {
const { t, language } = useTranslation()
const toast = useToast()
@@ -67,7 +75,11 @@ export default function MJourneyEntrySheet({
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [pendingProviderGroups, setPendingProviderGroups] = useState<PendingProviderGroup[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false)
const [showExternal, setShowExternal] = useState(false)
const [externalProvider, setExternalProvider] = useState<string | null>(null)
const [providers, setProviders] = useState<{ id: string; name: string }[]>([])
const [saving, setSaving] = useState(false)
const [captureOnly, setCaptureOnly] = useState(quickCapture)
const [locating, setLocating] = useState(false)
@@ -75,6 +87,55 @@ export default function MJourneyEntrySheet({
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const cameraRef = useRef<HTMLInputElement>(null)
// A save that creates the entry and then fails on the provider photos keeps
// the sheet open; without the id of what was just created, the retry would
// create a second entry (#1808).
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
// The addon list is already in the store — this only probes which of the
// photo providers is actually connected for this user.
const addons = useAddonStore(s => s.addons)
const addonsLoaded = useAddonStore(s => s.loaded)
const photoProviders = useMemo(
() => addons.filter(a => a.type === 'photo_provider' && a.enabled).map(a => ({ id: a.id, name: a.name })),
[addons],
)
useEffect(() => {
if (readOnly) return
// App.tsx loads the addon list on boot, so this only reacts to it. Kicking off
// a second load from here would race the other consumers, and loadAddons
// overwrites the list unconditionally when it lands.
if (!addonsLoaded) return
if (photoProviders.length === 0) { setProviders([]); return }
let active = true
;(async () => {
const connected: { id: string; name: string }[] = []
for (const provider of photoProviders) {
try {
const res = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' })
if (res.ok && (await res.json()).connected) connected.push(provider)
} catch { /* provider stays hidden */ }
}
if (active) setProviders(connected)
})()
return () => { active = false }
}, [readOnly, addonsLoaded, photoProviders])
const activeProvider = externalProvider || providers[0]?.id || null
const queuedProviderPhotos = pendingProviderGroups.reduce((sum, group) => sum + group.assetIds.length, 0)
const providerExistingAssetIds = new Set<string>()
if (activeProvider) {
photos.forEach(photo => {
if (photo.provider === activeProvider && photo.asset_id) providerExistingAssetIds.add(photo.asset_id)
})
pendingProviderGroups.forEach(group => {
if (group.provider === activeProvider) group.assetIds.forEach(assetId => providerExistingAssetIds.add(assetId))
})
}
const contextLocation = isValidGeoPoint({ lat: locationLat ?? NaN, lng: locationLng ?? NaN })
? { lat: locationLat!, lng: locationLng!, name: locationName || undefined }
: null
useEffect(() => {
if (!quickCapture || readOnly || entry.location_lat != null || entry.location_lng != null) return
@@ -120,7 +181,8 @@ export default function MJourneyEntrySheet({
cons.filter(c => c.trim()).join('\n') !== (entry.pros_cons?.cons ?? []).join('\n') ||
tags.join('\n') !== (entry.tags ?? []).join('\n') ||
pendingFiles.length > 0 ||
pendingLinkIds.length > 0
pendingLinkIds.length > 0 ||
pendingProviderGroups.length > 0
const handleClose = () => {
if (!captureOnly && !readOnly && isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
@@ -142,10 +204,12 @@ export default function MJourneyEntrySheet({
weather: weather || null,
tags: tags.filter(tag => tag.trim()),
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
type: entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)
type: entry.type === 'skeleton'
&& (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0 || pendingProviderGroups.length > 0)
? 'entry'
: undefined,
})
}, persistedEntryIdRef.current ?? undefined)
if (entryId > 0) persistedEntryIdRef.current = entryId
if (pendingFiles.length > 0 && entryId) {
const toUpload = pendingFiles
setUploadProgress({ done: 0, total: toUpload.length })
@@ -168,6 +232,20 @@ export default function MJourneyEntrySheet({
try { await journeyApi.linkPhoto(entryId, photoId) } catch { /* linked photo stays in gallery */ }
}
}
if (pendingProviderGroups.length > 0 && entryId && onAddProviderPhotos) {
const failed: PendingProviderGroup[] = []
for (const group of pendingProviderGroups) {
try { await onAddProviderPhotos(entryId, group) } catch { failed.push(group) }
}
if (failed.length > 0) {
// Keep the sheet open with the failed groups queued so the next save
// retries them instead of losing the selection.
setPendingProviderGroups(failed)
toast.error(t('journey.editor.externalPhotosPartialFailed', { failed: String(failed.length), total: String(pendingProviderGroups.length) }))
return
}
setPendingProviderGroups([])
}
onDone()
} finally {
setSaving(false)
@@ -281,7 +359,7 @@ export default function MJourneyEntrySheet({
type="button"
onClick={() => (captureOnly ? cameraRef : fileRef).current?.click()}
disabled={saving}
className="flex flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] border-dashed border-[color:var(--m-rowbr)] p-3 text-[0.75rem] font-semibold text-m-muted disabled:opacity-50"
className="flex min-w-0 flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] border-dashed border-[color:var(--m-rowbr)] p-3 text-center text-[0.75rem] font-semibold text-m-muted disabled:opacity-50"
>
{uploadProgress ? (
<>
@@ -297,9 +375,13 @@ export default function MJourneyEntrySheet({
</button>
<button
type="button"
onClick={() => captureOnly ? fileRef.current?.click() : setShowGalleryPick(v => !v)}
onClick={() => {
if (captureOnly) { fileRef.current?.click(); return }
setShowGalleryPick(v => !v)
setShowExternal(false)
}}
disabled={!captureOnly && galleryPhotos.length === 0}
className={`flex flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] p-3 text-[0.75rem] font-semibold disabled:opacity-40 ${
className={`flex min-w-0 flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] p-3 text-center text-[0.75rem] font-semibold disabled:opacity-40 ${
!captureOnly && showGalleryPick
? 'border-[color:var(--m-act)] text-m-ink'
: 'border-dashed border-[color:var(--m-rowbr)] text-m-muted'
@@ -308,8 +390,109 @@ export default function MJourneyEntrySheet({
<Image size={14} strokeWidth={2} />
{captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')}
</button>
{/* Immich/Synology, the same source the desktop editor offers (#1808).
Only shown once a provider is actually connected — on a phone a
button that can only say "nothing connected" is not worth its width. */}
{!captureOnly && providers.length > 0 && (
<button
type="button"
onClick={() => { setShowExternal(v => !v); setShowGalleryPick(false) }}
disabled={saving}
className={`flex min-w-0 flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] p-3 text-center text-[0.75rem] font-semibold disabled:opacity-50 ${
showExternal
? 'border-[color:var(--m-act)] text-m-ink'
: 'border-dashed border-[color:var(--m-rowbr)] text-m-muted'
}`}
>
<Images size={14} strokeWidth={2} />
{t('journey.editor.externalPhotos')}
</button>
)}
</div>
{/* Outside the panel: picking collapses it again, and the queue has
to stay visible until the save actually writes it. */}
{!captureOnly && queuedProviderPhotos > 0 && (
<button
type="button"
onClick={() => setPendingProviderGroups([])}
className="mt-2 rounded-full border border-[color:var(--m-rowbr)] px-3 py-[5px] font-geist text-[0.65625rem] font-semibold text-m-muted"
>
{queuedProviderPhotos} {t('journey.editor.externalPhotosQueued')} · {t('common.clear')}
</button>
)}
{!captureOnly && showExternal && activeProvider && (
<div
className="mt-2 flex flex-col overflow-hidden rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]"
style={{ height: 'min(46vh, 420px)' }}
>
<div className="flex flex-none items-center gap-2 border-b border-[color:var(--m-rowbr)] px-3 py-2">
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] font-semibold text-m-muted">
{contextLocation?.name
? `${t('journey.editor.externalPhotosNearby')} · ${contextLocation.name}`
: t('journey.editor.externalPhotosNoLocation')}
</span>
</div>
{providers.length > 1 && (
<div className="flex flex-none gap-1 overflow-x-auto border-b border-[color:var(--m-rowbr)] px-3 py-2">
{providers.map(provider => (
<button
key={provider.id}
type="button"
data-testid={`journey-external-provider-${provider.id}`}
onClick={() => setExternalProvider(provider.id)}
className={`whitespace-nowrap rounded-full px-[10px] py-[4px] text-[0.6875rem] font-bold ${
activeProvider === provider.id ? 'bg-m-act text-m-actfg' : 'text-m-muted'
}`}
>
{provider.name}
</button>
))}
</div>
)}
{/* `embedded` is load-bearing: the standalone picker is
position:fixed, which the transformed MSheet panel would
anchor to itself and then clip. */}
<div className="min-h-0 flex-1">
<ProviderPicker
key={`${activeProvider}-${entryDate}`}
provider={activeProvider}
userId={userId}
entries={[entry]}
trips={trips}
existingAssetIds={providerExistingAssetIds}
initialDate={entryDate}
contextLocation={contextLocation}
initialEntryId={entry.id || null}
embedded
onClose={() => setShowExternal(false)}
onAdd={async groups => {
setPendingProviderGroups(previous => {
const next = [...previous]
for (const group of groups) {
const existing = next.find(item => item.provider === activeProvider && item.passphrase === group.passphrase)
if (existing) {
const seen = new Set(existing.assetIds)
group.assetIds.forEach((assetId, index) => {
if (seen.has(assetId)) return
seen.add(assetId)
existing.assetIds.push(assetId)
existing.mediaTypes?.push(group.mediaTypes?.[index] || 'image')
})
} else {
next.push({ ...group, provider: activeProvider })
}
}
return next
})
setShowExternal(false)
}}
/>
</div>
</div>
)}
{!captureOnly && showGalleryPick && (
<div className="mt-2 max-h-[160px] overflow-y-auto rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-2">
<div className="grid grid-cols-5 gap-[6px]">
@@ -12,9 +12,10 @@ type Provider = NonNullable<Settings['llm_provider']>
/**
* Mobile-native twin of components/Settings/LlmConnectionSection. Same per-user
* AI-parsing model logic (provider/model/base URL/key/multimodal, key never
* prefilled), rebuilt on the MSet* card system: the provider CustomSelect becomes
* an MSetSelectRow + MSetPickerSheet, the toggle becomes MToggle. Presentation only.
* AI-parsing model logic (provider/model/key/multimodal, key never prefilled,
* free-form endpoint moved to the instance config per #1772), rebuilt on the
* MSet* card system: the provider CustomSelect becomes an MSetSelectRow +
* MSetPickerSheet, the toggle becomes MToggle. Presentation only.
*/
export default function MLlmConnectionSection(): React.ReactElement {
const { t } = useTranslation()
@@ -22,10 +23,10 @@ export default function MLlmConnectionSection(): React.ReactElement {
const settings = useSettingsStore(s => s.settings)
const isLoaded = useSettingsStore(s => s.isLoaded)
const updateSettings = useSettingsStore(s => s.updateSettings)
const loadSettings = useSettingsStore(s => s.loadSettings)
const [provider, setProvider] = useState<Provider>('local')
const [provider, setProvider] = useState<Provider>('openai')
const [model, setModel] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [multimodal, setMultimodal] = useState(false)
const [hasStoredKey, setHasStoredKey] = useState(false)
@@ -33,22 +34,20 @@ export default function MLlmConnectionSection(): React.ReactElement {
const [providerOpen, setProviderOpen] = useState(false)
// Hydrate from the loaded settings. llm_api_key arrives masked, so we only use
// its presence to drive the placeholder — never the value itself.
// its presence to drive the placeholder, never the value itself. A stored
// 'local' from before #1772 shows as OpenAI (local state only, nothing is
// saved until Save is pressed).
useEffect(() => {
if (!isLoaded) return
setProvider(settings.llm_provider || 'local')
const stored = settings.llm_provider || 'openai'
setProvider(stored === 'local' ? 'openai' : stored)
setModel(settings.llm_model || '')
setBaseUrl(settings.llm_base_url || '')
setMultimodal(settings.llm_multimodal === true)
setHasStoredKey(!!settings.llm_api_key)
}, [isLoaded, settings.llm_provider, settings.llm_model, settings.llm_base_url, settings.llm_multimodal, settings.llm_api_key])
const needsKey = provider !== 'local'
const showBaseUrl = provider === 'local' || provider === 'openai'
}, [isLoaded, settings.llm_provider, settings.llm_model, settings.llm_multimodal, settings.llm_api_key])
const providerOptions = useMemo(
() => [
{ value: 'local', label: t('settings.aiParsing.providerLocal') },
{ value: 'openai', label: t('settings.aiParsing.providerOpenai') },
{ value: 'anthropic', label: t('settings.aiParsing.providerAnthropic') },
],
@@ -62,7 +61,9 @@ export default function MLlmConnectionSection(): React.ReactElement {
const payload: Partial<Settings> = {
llm_provider: provider,
llm_model: model.trim(),
llm_base_url: showBaseUrl ? baseUrl.trim() : '',
// Always cleared: the endpoint is instance configuration now, and this
// also drops a value left over from before #1772.
llm_base_url: '',
llm_multimodal: multimodal,
}
// Send the key only when the user typed a new one — a blank field means
@@ -74,6 +75,11 @@ export default function MLlmConnectionSection(): React.ReactElement {
if (key) setHasStoredKey(true)
toast.success(t('settings.aiParsing.toast.saved'))
} catch {
// updateSettings patches the store before the request and keeps the patch
// when the request fails, so a refused save (the 403 from #1772, or any
// other error) would leave the form showing a value the server never
// stored. Pull the stored settings back in so what is on screen is real.
await loadSettings()
toast.error(t('settings.aiParsing.toast.saveError'))
} finally {
setSaving(false)
@@ -90,6 +96,7 @@ export default function MLlmConnectionSection(): React.ReactElement {
trailing={<ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />}
onClick={() => setProviderOpen(true)}
/>
<MSetHint>{t('settings.aiParsing.localAdminOnly')}</MSetHint>
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.aiParsing.model')}</MSetEyebrow>
<MSetInput
@@ -100,33 +107,17 @@ export default function MLlmConnectionSection(): React.ReactElement {
placeholder="qwen3:8b"
/>
{showBaseUrl && (
<>
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.aiParsing.baseUrl')}</MSetEyebrow>
<MSetInput
type="url"
autoComplete="off"
value={baseUrl}
onChange={e => setBaseUrl(e.target.value)}
placeholder="http://localhost:11434"
/>
<MSetHint>{t('settings.aiParsing.baseUrlHint')}</MSetHint>
</>
)}
{needsKey && (
<>
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.aiParsing.apiKey')}</MSetEyebrow>
<MSetInput
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
autoComplete="off"
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
/>
<MSetHint>{t('settings.aiParsing.apiKeyHint')}</MSetHint>
</>
)}
{/* Both remaining providers are hosted and need a key, so this is no longer
conditional (#1772). */}
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.aiParsing.apiKey')}</MSetEyebrow>
<MSetInput
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
autoComplete="off"
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
/>
<MSetHint>{t('settings.aiParsing.apiKeyHint')}</MSetHint>
<div className="mt-3">
<MSetRow
@@ -1,11 +1,13 @@
import { ReactNode, useEffect, useMemo, useState } from 'react'
import {
Bookmark, Check, CheckCheck, ListChecks, MapPin, MoreHorizontal, Plus,
Bookmark, Check, CheckCheck, CheckCircle2, ListChecks, Loader2, MapPin, MoreHorizontal, Plus,
SlidersHorizontal, Tag, Trash2, X,
} from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
import { useTripStore } from '../../../../store/tripStore'
import { useAddonStore } from '../../../../store/addonStore'
import { useToast } from '../../../../components/shared/Toast'
import { collectionsApi } from '../../../../api/collections'
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import { resolveTrackColor } from '../../../../components/Map/trackColors'
@@ -43,6 +45,8 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
const [saveToListOpen, setSaveToListOpen] = useState(false)
const [markVisitedBusy, setMarkVisitedBusy] = useState(false)
const toast = useToast()
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
// Entering the browser from the edit segment starts on the unplanned pool.
@@ -85,6 +89,23 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
return next
})
/** Mark the selection visited in every list it is saved to (#1469). */
const markSelectionVisited = async () => {
const ids = [...selectedIds]
if (ids.length === 0 || markVisitedBusy) return
setMarkVisitedBusy(true)
try {
const { updated, places: matched } = await collectionsApi.setStatusFromTrip(trip.id, ids, 'visited')
if (updated === 0) toast.info(t('collections.markVisitedNone'))
else toast.success(t('collections.markedVisitedTrip', { count: matched ?? 0 }))
exitSelectMode()
} catch {
toast.error(t('common.error'))
} finally {
setMarkVisitedBusy(false)
}
}
// Compare the ids, not just the counts: a place removed remotely while another
// one is selected keeps the sizes equal without the sets matching.
const allSelected = filtered.length > 0 && filtered.every(p => selectedIds.has(p.id))
@@ -223,6 +244,15 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
<Bookmark size={14} strokeWidth={2} />
</BulkBtn>
)}
{collectionsEnabled && (
<BulkBtn
label={t('collections.markVisitedSelection')}
disabled={selectedIds.size === 0 || markVisitedBusy}
onClick={markSelectionVisited}
>
{markVisitedBusy ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle2 size={14} strokeWidth={2} />}
</BulkBtn>
)}
<BulkBtn label={t('places.deleteSelected')} disabled={selectedIds.size === 0} onClick={() => setConfirmDeleteOpen(true)}>
<Trash2 size={14} strokeWidth={2} />
</BulkBtn>
@@ -258,6 +258,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
note: note.trim() || null,
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from 'react'
import { MapPin } from 'lucide-react'
import { MapPin, Plus } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import {
DEFAULT_FORM,
@@ -8,6 +8,8 @@ import {
type ResultField,
} from '../../../../components/Planner/PlaceFormModal.helpers'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { useAddonStore } from '../../../../store/addonStore'
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
import PlPlaceSearch, { type PlSearchPick } from './PlPlaceSearch'
import PlCategoryPicker from './PlCategoryPicker'
import PlTimeFields from './PlTimeFields'
@@ -17,6 +19,8 @@ import type { TripPlanner } from '../MTripShell'
export interface MPlaceEditSheetProps {
planner: TripPlanner
/** Opens the Costs editor for this place's linked expense (#1298). */
onOpenExpense: (req: BookingExpenseRequest) => void
}
// #1152: same duplicate heuristic as the desktop form — shared Google Place ID,
@@ -51,7 +55,7 @@ function findDuplicateName(
* planner.handleSavePlace, which owns the assignment-time split, pending-file
* upload and undo.
*/
export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) {
export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSheetProps) {
const {
t, toast, places, assignments, canUploadFiles,
showPlaceForm, setShowPlaceForm,
@@ -68,6 +72,10 @@ export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) {
const [isSaving, setIsSaving] = useState(false)
const [resolvingPick, setResolvingPick] = useState(false)
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
// Set right before submit: the place has to exist before an expense can point
// at it, exactly like MReservationSheet does it.
const expenseIntentRef = useRef(false)
const [deleteArmed, setDeleteArmed] = useState(false)
// Open-time snapshot: closing clears the planner flags immediately, but the
// sheet still shows through its exit animation — render off the snapshot so
@@ -221,15 +229,21 @@ export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) {
return
}
}
const withExpense = expenseIntentRef.current
expenseIntentRef.current = false
setIsSaving(true)
try {
await handleSavePlace({
const saved = await handleSavePlace({
...form,
lat: form.lat ? parseFloat(form.lat) : null,
lng: form.lng ? parseFloat(form.lng) : null,
category_id: form.category_id || null,
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
})
const savedId = saved?.id ?? sheetPlace?.id ?? null
if (withExpense && savedId) {
onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } })
}
handleClose()
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('places.saveError'))
@@ -360,6 +374,23 @@ export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) {
onRemove={idx => setPendingFiles(prev => prev.filter((_, i) => i !== idx))}
/>
)}
{/* COSTS — same block, same flow as the booking sheet (#1298) */}
{isBudgetEnabled && (
<>
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('reservations.costsLabel')}</Eyebrow>
<button
type="button"
onClick={() => { expenseIntentRef.current = true; handleSubmit() }}
disabled={!form.name.trim() || isSaving}
className="flex w-full items-center justify-center gap-[6px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[11px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-40"
>
<Plus size={13} strokeWidth={2.2} />
{t('reservations.createExpense')}
</button>
<div className="mt-[5px] font-geist text-[0.625rem] text-m-faint">{t('places.createExpenseHint')}</div>
</>
)}
</div>
<FormSheetFooter
@@ -73,7 +73,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
<MImportSheet planner={planner} open={sheet?.id === 'import'} onClose={shell.closeSheet} />
{/* ── Planner-flag editors (also serve ?create= and the import review) ── */}
<MPlaceEditSheet planner={planner} />
<MPlaceEditSheet planner={planner} onOpenExpense={openBookingExpense} />
<MReservationSheet planner={planner} onOpenExpense={openBookingExpense} />
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import { ChevronLeft, ChevronRight, Download, ExternalLink, X } from 'lucide-react'
import { getAuthUrl } from '../../../../api/authUrl'
import { downloadFile, openFile } from '../../../../utils/fileDownload'
import { lockBodyScroll } from '../../../../utils/bodyScrollLock'
import { isVideo } from '../../../../components/Files/FileManager.helpers'
import VideoPlayer from '../../../../components/Journey/VideoPlayerLazy'
import type { TranslationFn, TripFile } from '../../../../types'
@@ -56,11 +57,7 @@ export default function MFileLightbox({ files, index, onIndexChange, onClose, t
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [index, files.length])
useEffect(() => {
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prevOverflow }
}, [])
useEffect(() => lockBodyScroll(), [])
if (!file) return null
@@ -767,6 +767,7 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
}) {
const { t, tripId, tripActions } = planner
const [bagPickerOpen, setBagPickerOpen] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const [creatingBag, setCreatingBag] = useState(false)
const [newBagName, setNewBagName] = useState('')
@@ -821,16 +822,26 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
{isPlaceholder ? t('packing.addItemPlaceholder') : item.name}
</span>
{/* Sharing state is an icon, not a sentence (#1525): "Taken care of by
Alexander" spelled out took a third of the row away from the item
name. The full wording lives on the pill as its label. */}
{badgeSharedToMe && (
<span className="flex flex-none items-center gap-1 whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.59375rem] font-bold text-m-muted">
<HandHelping size={9} strokeWidth={2.2} />
{t('packing.takenCareOf', { name: item.owner_username || '' })}
<span
className="flex h-5 w-5 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted"
title={t('packing.takenCareOf', { name: item.owner_username || '' })}
aria-label={t('packing.takenCareOf', { name: item.owner_username || '' })}
>
<HandHelping size={11} strokeWidth={2.2} />
</span>
)}
{!badgeSharedToMe && badgeSharedByMe && (
<span className="flex flex-none items-center gap-1 whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.59375rem] font-bold text-m-muted">
<UserRound size={9} strokeWidth={2.2} />
{t('packing.sharedWithCount', { count: recipients.length })}
<span
className="flex flex-none items-center gap-[2px] rounded-full bg-[color:var(--m-ic)] px-[5px] py-[3px] font-geist text-[0.59375rem] font-bold text-m-muted"
title={t('packing.sharedWithCount', { count: recipients.length })}
aria-label={t('packing.sharedWithCount', { count: recipients.length })}
>
<UserRound size={10} strokeWidth={2.2} />
{recipients.length}
</span>
)}
{!badgeSharedToMe && !badgeSharedByMe && badgeBroughtBy && (
@@ -850,16 +861,18 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
</span>
)}
{editMode && bagTrackingEnabled && (
{/* A weight nobody entered is not worth a column: "— g" on every row
cost as much width as a real value (#1525). */}
{editMode && bagTrackingEnabled && item.weight_grams != null && (
<span className="flex-none font-geist text-[0.625rem] font-semibold tabular-nums text-m-faint">
{item.weight_grams != null ? formatWeight(item.weight_grams) : '— g'}
{formatWeight(item.weight_grams)}
</span>
)}
{bagTrackingEnabled && (
<button
type="button"
onClick={() => setBagPickerOpen(v => !v)}
onClick={() => { setMenuOpen(false); setBagPickerOpen(v => !v) }}
aria-label={t('packing.bags')}
className="h-[18px] w-[18px] flex-none rounded-full"
style={bag
@@ -868,18 +881,43 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
/>
)}
{/* Edit and delete moved behind one button (#1525): two round targets
plus their gaps were the widest thing on an edit-mode row, and
neither is used often enough to earn a permanent seat. */}
{editMode && canEdit && (
<>
<button type="button" onClick={onEdit} aria-label={t('common.edit')} className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
<Pencil size={11} strokeWidth={2} />
</button>
<button type="button" onClick={onDelete} aria-label={t('common.delete')} className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
<Trash2 size={11} strokeWidth={2} />
</button>
</>
<button
type="button"
onClick={() => { setBagPickerOpen(false); setMenuOpen(v => !v) }}
aria-label={t('mobileTrip.more')}
aria-expanded={menuOpen}
className={`flex h-7 w-7 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] ${menuOpen ? 'text-m-ink' : 'text-m-muted'}`}
>
<MoreHorizontal size={13} strokeWidth={2} />
</button>
)}
</div>
{menuOpen && editMode && canEdit && (
<div className="mb-[6px] ml-[28px] overflow-hidden rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)] backdrop-blur-[24px]">
<button
type="button"
onClick={() => { setMenuOpen(false); onEdit() }}
className="flex w-full items-center gap-[9px] border-b border-[color:var(--m-rowbr)] px-3 py-2 text-left text-[0.75rem] font-medium text-m-ink"
>
<Pencil size={12} strokeWidth={2} />
{t('common.edit')}
</button>
<button
type="button"
onClick={() => { setMenuOpen(false); onDelete() }}
className="flex w-full items-center gap-[9px] px-3 py-2 text-left text-[0.75rem] font-medium text-[color:var(--m-st-danger)]"
>
<Trash2 size={12} strokeWidth={2} />
{t('common.delete')}
</button>
</div>
)}
{bagPickerOpen && bagTrackingEnabled && (
<div className="mb-[6px] ml-[28px] overflow-hidden rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)] backdrop-blur-[24px]">
<button type="button" onClick={() => assignBag(null)} className="flex w-full items-center gap-[9px] border-b border-[color:var(--m-rowbr)] px-3 py-2 text-left text-[0.75rem] font-medium text-m-muted">
+18 -5
View File
@@ -21,7 +21,8 @@ const WEEKDAY_KEYS_SUNDAY = ['vacay.sun', 'vacay.mon', 'vacay.tue', 'vacay.wed',
* Mobile Vacay screen: year pill header, person card with inline entitlement
* stepper, member/legend chips and the 12-month grid — or, in edit mode, the
* single-month editor with quick month nav and the person/company mode
* switch. The dock FAB flips between the two views.
* switch. The grid navigates (month chip or day cell open that month's editor),
* the editor logs; the screen's own FAB in the dock centre flips back.
*/
export default function MVacay() {
const { t } = useTranslation()
@@ -29,7 +30,7 @@ export default function MVacay() {
if (v.loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex h-dvh items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-[color:var(--m-ink)]" />
</div>
)
@@ -44,7 +45,8 @@ export default function MVacay() {
const weekdayKeys = v.weekStart === 0 ? WEEKDAY_KEYS_SUNDAY : WEEKDAY_KEYS_MONDAY
return (
<div className="relative h-full">
// h-dvh, not h-full: the shell stopped providing a definite height (#1809).
<div className="relative h-dvh">
{/* Header */}
<div className="absolute left-4 right-4 z-[5] flex items-center gap-2 top-[var(--m-safe-top,12px)]">
<MIconBtn onClick={() => v.setSheet('invite')} ariaLabel={t('vacay.inviteUser')}>
@@ -172,7 +174,17 @@ export default function MVacay() {
year, Jul–Jun once the leave year is shifted. */}
{v.months.map(({ year, month }, slot) => (
<div key={`${year}-${month}`} className="rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] p-2">
<div className="mb-1 text-[0.75rem] font-extrabold capitalize">{v.monthNamesShort[slot]}</div>
{/* The month name is the comfortable way into the editor: the mini
cells below it are ~21px on a phone, precise enough to navigate
with but not to aim at (#1811). Same chip as the edit view's
month nav, so it reads as tappable. */}
<button
type="button"
onClick={() => v.openMonthSlot(slot)}
className="mb-[6px] w-full rounded-[11px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[7px] text-center text-[0.75rem] font-extrabold capitalize"
>
{v.monthNamesShort[slot]}
</button>
<MVacayMonth
year={year}
month={month}
@@ -305,7 +317,8 @@ export default function MVacay() {
</div>
)}
{/* View/edit FAB — replaces the dock "+" on this screen */}
{/* View/edit FAB. It sits in the dock's centre slot, which MBottomNav leaves
empty on /vacay so this stays the only action there (#1811). */}
<button
type="button"
onClick={v.toggleView}
@@ -10,6 +10,7 @@ interface MVacayMonthProps {
ctx: DayVisualContext
tripDates: Set<string>
tripDotColor: string
/** mini cells zoom into that month, full cells log the day (#1811). */
onDayTap: (date: string) => void
}
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { ArrowRightLeft, Building2, Calendar, CalendarRange, CalendarX, ChevronDown, Globe, GraduationCap, Plus, Trash2, Unlink, X } from 'lucide-react'
import { ArrowRightLeft, Building2, Calendar, CalendarRange, CalendarX, ChevronDown, Globe, GraduationCap, Loader2, Plus, Trash2, Unlink, X } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
@@ -442,6 +442,30 @@ function ColorSwatch({ color, onPick }: { color: string; onPick: (color: string)
)
}
/**
* Region options for a holiday-calendar country.
*
* The loaded list is tagged with the country it belongs to, so switching country
* drops the previous list in the same render instead of leaving it selectable while
* the next request is still running (#1813: picking a German region for the
* Netherlands was possible that way). `loadingRegions` tells "not answered yet"
* apart from "this country has no regions"; on the list alone both are empty.
*/
function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') {
const [loaded, setLoaded] = useState<{ country: string; options: Option[] }>({ country: '', options: [] })
useEffect(() => {
if (!country) return
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
let stale = false
load(country).then(options => { if (!stale) setLoaded({ country, options }) })
return () => { stale = true }
}, [country, calendarType])
const ready = loaded.country === country
return { regions: ready ? loaded.options : [], loadingRegions: Boolean(country) && !ready }
}
function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpdate, onDelete }: {
cal: VacayHolidayCalendar
countries: Option[]
@@ -451,19 +475,14 @@ function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpd
}) {
const { t } = useTranslation()
const [label, setLabel] = useState(cal.label || '')
const [regions, setRegions] = useState<Option[]>([])
// A school-holiday group carries a `|group:…` suffix — strip it before the
// country is read off, otherwise the region lookup finds nothing.
const [baseRegion] = cal.region.split('|')
const country = baseRegion.split('-')[0]
const region = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : ''
const { regions } = useRegionOptions(country, calendarType)
useEffect(() => { setLabel(cal.label || '') }, [cal.label])
useEffect(() => {
if (!country) { setRegions([]); return }
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
load(country).then(setRegions)
}, [country, calendarType])
return (
<div className="mt-2 rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
@@ -499,19 +518,16 @@ function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, o
const [region, setRegion] = useState('')
const [color, setColor] = useState(CALENDAR_COLORS[0])
const [label, setLabel] = useState('')
const [regions, setRegions] = useState<Option[]>([])
const [baseRegion] = region.split('|')
const country = baseRegion.split('-')[0] || ''
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : ''
const { regions, loadingRegions } = useRegionOptions(country, calendarType)
useEffect(() => {
if (!country) { setRegions([]); return }
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
load(country).then(setRegions)
}, [country, calendarType])
const canAdd = Boolean(country) && (regions.length === 0 || selectedRegion !== '')
// Adding is blocked while the region list is on its way: an empty list would
// otherwise pass as "this country needs no region" and create a calendar that
// draws nothing (#1813). The button shows a spinner so the wait is visible.
const canAdd = Boolean(country) && !loadingRegions && (regions.length === 0 || selectedRegion !== '')
return (
<div className="mt-2 rounded-[14px] border-[1.5px] border-dashed border-[color:var(--m-rowbr)] p-3">
@@ -534,9 +550,11 @@ function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, o
<button
type="button"
disabled={!canAdd}
aria-busy={loadingRegions}
onClick={() => onAdd({ region: region || country, color, label: label.trim() || null })}
className="mt-[7px] w-full rounded-[10px] bg-m-act p-[9px] text-center text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-40"
className="mt-[7px] flex w-full items-center justify-center gap-[6px] rounded-[10px] bg-m-act p-[9px] text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-40"
>
{loadingRegions && <Loader2 size={13} strokeWidth={2} className="animate-spin" />}
{t('vacay.add')}
</button>
</div>
+20 -5
View File
@@ -172,10 +172,25 @@ export function useMVacay() {
}
}, [isFused, currentUser?.id, setSelectedUserId])
// Zoom the year overview into one month's editor (#1811). The overview used to
// swallow every tap, leaving the pen FAB as the only way in; it is a way in
// itself now, while its ~21px mini cells stay out of data changes.
const openMonthSlot = useCallback((slot: number) => {
setMonthSlot(slot)
setView('edit')
}, [])
const handleDayTap = useCallback(async (dateStr: string) => {
// The year overview is read-only — logging only happens in edit mode
// (neither vacation days nor company holidays can be set while viewing).
if (view !== 'edit') return
// Outside edit mode a tap navigates instead of logging: it opens the month
// the day belongs to, where the cells are big enough to hit reliably (#1811).
if (view !== 'edit') {
const [year, month] = dateStr.split('-').map(Number)
// Resolved against the window months, not the calendar month: with a
// shifted leave year (#737) slot 0 is not January.
const slot = months.findIndex(m => m.year === year && m.month === month - 1)
if (slot >= 0) openMonthSlot(slot)
return
}
if (mode === 'company') {
if (!companyHolidaysEnabled) return
await toggleCompanyHoliday(dateStr)
@@ -193,7 +208,7 @@ export function useMVacay() {
}
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
await toggleEntry(dateStr, selectedUserId || undefined, halfDay ? 0.5 : 1, compDay ? 'comp' : 'vacation')
}, [view, mode, halfDay, compDay, companyHolidaysEnabled, blockWeekends, weekendDays, companyHolidaySet, toggleEntry, toggleCompanyHoliday, selectedUserId, currentUser?.id, entryMap])
}, [view, months, openMonthSlot, mode, halfDay, compDay, companyHolidaysEnabled, blockWeekends, weekendDays, companyHolidaySet, toggleEntry, toggleCompanyHoliday, selectedUserId, currentUser?.id, entryMap])
// Entitlement stepper: never below what is already used this year
// (carried-over days cover the difference when used > entitlement).
@@ -243,7 +258,7 @@ export function useMVacay() {
blockWeekends, companyHolidaysEnabled, holidaysEnabled, weekStart, weekendDays,
dayCtx, monthNamesShort, monthNameLong,
selectedUser, selectedColor, selectedStat, selectedUserId, selectPerson,
handleDayTap, allowInc, allowDec,
handleDayTap, openMonthSlot, allowInc, allowDec,
prevYear, nextYear, prevMonth, nextMonth, toggleView, goBack,
}
}
@@ -99,9 +99,10 @@ function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext
// onto whatever visual this day resolves to rather than picking a single winner.
const withSchool = (v: DayVisual): DayVisual => (schoolColors.length > 0 ? { ...v, school: schoolColors } : v)
if (dateStr === ctx.todayStr) {
return withSchool({ background: 'transparent', numColor: 'var(--m-ink)', boxShadow: 'inset 0 0 0 1.5px var(--m-ink)' })
}
// Today is deliberately NOT decided here: it is a ring around whatever the day
// already is, applied in dayVisual. Resolving it as its own visual meant a
// vacation day or company holiday logged for today was stored and counted but
// drawn as an empty cell.
if (ctx.companyHolidaysEnabled && ctx.companyHolidaySet.has(dateStr)) {
return withSchool({ background: '#F5D9A6', numColor: '#8A5A00' })
}
@@ -139,6 +140,18 @@ function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext
export function dayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext): DayVisual {
const visual = baseDayVisual(dateStr, dayOfWeek, ctx)
// Today rings whatever the day resolved to, so a vacation day or company
// holiday logged for today keeps its fill and is visible.
if (dateStr === ctx.todayStr) {
const ring = 'inset 0 0 0 1.5px var(--m-ink)'
visual.boxShadow = visual.boxShadow ? `${ring}, ${visual.boxShadow}` : ring
// The strong ink only where the cell has nothing of its own to say. On a
// pastel fill the fill's own ink is the readable one, and in dark mode
// --m-ink is light and would vanish on it.
if (!visual.segments && (visual.background === 'transparent' || visual.background === 'var(--m-ic)')) {
visual.numColor = 'var(--m-ink)'
}
}
// Shared calendars (#444/#667) draw inset rings over the base cell — capped at
// two so tiny mini-grid cells stay readable. Nested inside the today ring.
const rings = [...new Set((ctx.sharedMap?.[dateStr] || []).map(m => m.color))].slice(0, 2)
+38 -3
View File
@@ -922,6 +922,41 @@ describe('AtlasPage', () => {
});
});
describe('FE-PAGE-ATLAS-051: bucket search results escape the panel that clips them', () => {
it('renders the result list outside the overflow-hidden desktop panel (#1899)', async () => {
server.use(
http.post('/api/maps/search', () => HttpResponse.json({
places: [
{ name: 'Amsterdam', address: 'Amsterdam, North Holland, Netherlands', lat: 52.37, lng: 4.89 },
{ name: 'New Amsterdam Island', address: 'French Southern and Antarctic Lands, France', lat: -37.8, lng: 77.5 },
{ name: 'City of Amsterdam', address: 'North Holland, Netherlands', lat: 52.35, lng: 4.9 },
],
})),
);
const user = userEvent.setup();
render(<AtlasPage />);
await waitFor(() => expect(screen.getAllByText('Bucket List').length).toBeGreaterThan(0));
await user.click(screen.getAllByText('Bucket List')[0]);
await waitFor(() => expect(screen.getAllByRole('button', { name: /add place/i }).length).toBeGreaterThan(0));
await user.click(screen.getAllByRole('button', { name: /add place/i })[0]);
const nameInput = await screen.findByPlaceholderText(/name \(country, city, place\.\.\.\)/i);
await user.type(nameInput, 'Amsterdam{Enter}');
// The first hit must be present — it is the one the panel used to swallow.
const firstHit = await screen.findByText('Amsterdam, North Holland, Netherlands');
const list = firstHit.closest('button')!.parentElement as HTMLElement;
// Portalled straight onto the body, so no ancestor's overflow can clip it.
expect(list.style.position).toBe('fixed');
expect(list.parentElement).toBe(document.body);
expect(list.closest('.overflow-hidden')).toBeNull();
expect(screen.getByText('City of Amsterdam')).toBeInTheDocument();
});
});
describe('FE-PAGE-ATLAS-033: GeoJSON with unvisited country covers onEachFeature else branch', () => {
it('loads map with visited FR and unvisited DE, covering both onEachFeature branches', async () => {
const geoJsonFRandDE = {
@@ -1291,7 +1326,7 @@ describe('AtlasPage', () => {
() => {
const els = screen.queryAllByText('Tokyo');
// Filter to those that are inside the search results dropdown (not the input itself)
const resultEl = els.find((el) => el.tagName !== 'INPUT' && el.closest('div[style*="position: absolute"]'));
const resultEl = els.find((el) => el.tagName !== 'INPUT' && el.closest('div[style*="position: fixed"]'));
if (!resultEl) throw new Error('Tokyo result not found in dropdown');
return resultEl;
},
@@ -1571,12 +1606,12 @@ describe('AtlasPage', () => {
await user.type(nameInput, 'Paris');
fireEvent.keyDown(nameInput, { key: 'Enter' });
// Wait for Paris result in the dropdown (absolute-positioned list)
// Wait for Paris result in the dropdown (portalled, fixed-position list)
const parisBtn = await waitFor(
() => {
const btns = Array.from(document.querySelectorAll('button'));
const btn = btns.find(
(b) => b.textContent?.includes('Paris') && b.closest('[style*="position: absolute"]'),
(b) => b.textContent?.includes('Paris') && b.closest('[style*="position: fixed"]'),
);
if (!btn) throw new Error('Paris dropdown result not found');
return btn;
+39 -6
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client'
@@ -6,7 +7,7 @@ import CustomSelect from '../components/shared/CustomSelect'
import EmptyState from '../components/shared/EmptyState'
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
import type { TranslationFn } from '../types'
import { A2_TO_A3, countryCodeToFlag, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
import { A2_TO_A3, countryCodeToFlag, findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
import { continentForCountry } from '@trek/shared'
import { useAtlas } from './atlas/useAtlas'
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
@@ -392,10 +393,20 @@ function AtlasPageDesktop(): React.ReactElement {
</button>
<button onClick={async () => {
const targetDate = bucketMonth > 0 && bucketYear > 0 ? `${bucketYear}-${String(bucketMonth).padStart(2, '0')}` : null
// #1898: one entry per target date. The dialog stays open on a
// duplicate so another month can be picked right away.
if (findBucketDuplicate(bucketList, { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate, lat: null, lng: null })) {
toast.error(t('atlas.bucketDuplicate'))
return
}
try {
const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate })
setBucketList(prev => [r.data.item, ...prev])
} catch (err) {
if (isBucketDuplicateError(err)) {
toast.error(t('atlas.bucketDuplicate'))
return
}
toast.error(getApiErrorMessage(err, t('common.error')))
}
setBucketMonth(0); setBucketYear(0)
@@ -470,6 +481,7 @@ interface SidebarContentProps {
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
const { language } = useTranslation()
const statsContentRef = useRef<HTMLDivElement>(null)
const bucketSearchRowRef = useRef<HTMLDivElement>(null)
const [statsWidth, setStatsWidth] = useState<number | undefined>(undefined)
useEffect(() => {
const el = statsContentRef.current
@@ -556,8 +568,8 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
{showBucketAdd ? (
<div style={{ padding: '8px 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* Search or manual name */}
<div style={{ position: 'relative' }}>
<div style={{ display: 'flex', gap: 4 }}>
<div>
<div ref={bucketSearchRowRef} style={{ display: 'flex', gap: 4 }}>
<input type="text" value={bucketForm.name || bucketSearch}
onChange={e => { const v = e.target.value; if (bucketForm.name) setBucketForm({ ...bucketForm, name: v }); else setBucketSearch(v) }}
onKeyDown={e => { if (e.key === 'Enter' && !bucketForm.name) onSearchBucket(); else if (e.key === 'Enter') onAddBucket(); if (e.key === 'Escape') setShowBucketAdd(false) }}
@@ -581,15 +593,36 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
</button>
)}
</div>
{bucketSearchResults.length > 0 && (
<div className="bg-surface-card border border-edge" style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, zIndex: 50, marginBottom: 4, borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', maxHeight: 160, overflowY: 'auto' }}>
{/* Portalled to the body: the panel that hosts this form clips its
overflow, which used to swallow the top of the list (#1899). */}
{bucketSearchResults.length > 0 && createPortal(
<div className="bg-surface-card border border-edge" style={{
position: 'fixed',
...(() => {
const rect = bucketSearchRowRef.current?.getBoundingClientRect()
if (!rect) return { left: 0, top: 0, width: 240, maxHeight: 160 }
const above = rect.top - 12
const below = window.innerHeight - rect.bottom - 12
const openUp = above >= below
return {
left: rect.left,
width: rect.width,
// Never taller than the room on that side, so the list scrolls
// instead of running off the edge of the viewport.
maxHeight: Math.min(160, openUp ? above : below),
...(openUp ? { bottom: window.innerHeight - rect.top + 4 } : { top: rect.bottom + 4 }),
}
})(),
zIndex: 99999, borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', overflowY: 'auto',
}}>
{bucketSearchResults.slice(0, 6).map((r, i) => (
<button key={i} onClick={() => onSelectBucketPoi(r)} className="border-b border-edge-faint" style={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%', padding: '6px 10px', borderTop: 'none', borderLeft: 'none', borderRight: 'none', background: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
<span className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{r.name}</span>
{r.address && <span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }}>{r.address}</span>}
</button>
))}
</div>
</div>,
document.body
)}
</div>
{/* Selected place indicator */}
@@ -378,6 +378,26 @@ describe('AtlasPage wiring', () => {
await waitFor(() => expect(body).toEqual({ name: 'Bretagne', country_code: 'FR', target_date: null }));
});
it('FE-PAGE-ATLASW-018b: a country already wishlisted for that date is not posted again (#1898)', async () => {
const post = vi.fn();
server.use(http.post('/api/addons/atlas/bucket-list', () => {
post();
return HttpResponse.json({ item: buildBucketItem({ id: 99, name: 'Germany', country_code: 'DE' }) });
}));
const atlas = setAtlas({
confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' },
bucketList: [buildBucketItem({ id: 7, name: 'Germany', country_code: 'DE' })],
});
render(<AtlasPage />);
fireEvent.click(screen.getByText('atlas.addToBucket'));
await waitFor(() => expect(toast).toHaveBeenCalledWith('atlas.bucketDuplicate', 'error', undefined));
expect(post).not.toHaveBeenCalled();
// The popup stays open on the date step so another month can be picked.
expect(atlas.setConfirmAction).not.toHaveBeenCalled();
});
it('FE-PAGE-ATLASW-018: a failing bucket post shows the error toast', async () => {
server.use(
http.post('/api/addons/atlas/bucket-list', () => HttpResponse.json({ error: 'Full' }, { status: 400 })),
+91 -29
View File
@@ -45,6 +45,7 @@ function JourneyDetailPageDesktop() {
hideSkeletons, setHideSkeletons,
mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
} = useJourneyDetail()
@@ -77,6 +78,20 @@ function JourneyDetailPageDesktop() {
const showMobileGallery = isMobile && view === 'gallery'
const isMobileChromeless = showMobileCombined || showMobileGallery
// Below 1024px the hero is gone, so its two actions have to live in the
// floating bar instead — they were unreachable there until #1848. Only one of
// the two hosts is mounted at a time, so both can carry the same labels.
const openBookPdf = () => {
import('../components/PDF/JourneyBookPDF').then(m => m.downloadJourneyBookPDF(current, { t, locale }))
}
const toggleSkeletons = async () => {
const next = !hideSkeletons
setHideSkeletons(next)
await journeyApi.updatePreferences(current.id, { hide_skeletons: next })
}
const skeletonLabel = hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')
const barButton = 'w-10 h-10 flex-shrink-0 rounded-lg bg-surface-elevated backdrop-blur-lg border border-edge shadow-lg text-content-secondary flex items-center justify-center hover:bg-surface-hover active:scale-95 transition-transform'
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
@@ -108,7 +123,8 @@ function JourneyDetailPageDesktop() {
/>
)}
{/* Floating top bar on mobile Journey + Gallery views: back | tabs+title | settings */}
{/* Floating top bar on mobile Journey + Gallery views:
back | tabs+title | book export, suggestions, settings */}
{isMobileChromeless && (
<div
className="fixed left-0 right-0 z-30 flex items-start justify-between gap-2 px-4"
@@ -117,19 +133,19 @@ function JourneyDetailPageDesktop() {
<button
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
className={barButton}
>
<ArrowLeft size={16} />
</button>
<div className="flex-1 min-w-0 flex justify-center">
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
<div className="flex bg-surface-elevated backdrop-blur-lg border border-edge rounded-lg overflow-hidden shadow-lg">
<button
onClick={() => setView('timeline')}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === 'timeline'
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
? 'bg-inverse text-inverse-text'
: 'text-content-muted hover:text-content-secondary'
}`}
>
<MapPin size={13} />
@@ -139,8 +155,8 @@ function JourneyDetailPageDesktop() {
onClick={() => setView('gallery')}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === 'gallery'
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
? 'bg-inverse text-inverse-text'
: 'text-content-muted hover:text-content-secondary'
}`}
>
<Grid size={13} />
@@ -149,17 +165,27 @@ function JourneyDetailPageDesktop() {
</div>
</div>
{canEditJourney ? (
<button
onClick={() => setShowSettings(true)}
aria-label={t('journey.settings.title')}
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
>
<MoreHorizontal size={16} />
<div className="flex items-center gap-1.5">
<button onClick={openBookPdf} aria-label={t('journey.pdf.saveAsPdf')} className={barButton}>
<Download size={16} />
</button>
) : (
<div className="w-10 h-10 flex-shrink-0" aria-hidden />
)}
<button
onClick={toggleSkeletons}
aria-label={skeletonLabel}
className={`${barButton} ${hideSkeletons ? 'bg-surface-selected' : ''}`}
>
{hideSkeletons ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
{canEditJourney && (
<button
onClick={() => setShowSettings(true)}
aria-label={t('journey.settings.title')}
className={barButton}
>
<MoreHorizontal size={16} />
</button>
)}
</div>
</div>
)}
@@ -183,8 +209,11 @@ function JourneyDetailPageDesktop() {
>
<div className={isMobile ? '' : 'w-full px-8 py-6'}>
{/* Hero card — hidden on mobile gallery/journey views (floating top bar handles branding there) */}
<div className={`px-4 md:px-0 mb-6 ${isMobileChromeless ? 'hidden' : ''}`}>
{/* Hero card — dropped on mobile gallery/journey views (floating top bar
handles branding there). Unmounted rather than `hidden`, so its
actions don't sit in the DOM as a second, invisible copy (#1848). */}
{!isMobileChromeless && (
<div className="px-4 md:px-0 mb-6">
<div className="rounded-none md:rounded-[28px] -mx-4 md:mx-0 overflow-hidden relative p-5 md:p-7" style={{ background: pickGradient(current.id), color: 'white' }}>
{current.cover_image && (
<>
@@ -210,36 +239,33 @@ function JourneyDetailPageDesktop() {
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
<div className="hidden md:flex items-center gap-2">
{lifecycle !== 'live' && lifecycle !== 'archived' && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
<div className="inline-flex h-[34px] items-center gap-1.5 px-3.5 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
</div>
)}
{lifecycle === 'archived' && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
<div className="inline-flex h-[34px] items-center gap-1.5 px-3.5 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
{t('journey.status.archived')}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1.5">
<button onClick={() => { import('../components/PDF/JourneyBookPDF').then(m => m.downloadJourneyBookPDF(current)) }} className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Download size={14} /></button>
<button onClick={openBookPdf} aria-label={t('journey.pdf.saveAsPdf')} className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Download size={14} /></button>
<div className="relative group">
<button
onClick={async () => {
const next = !hideSkeletons
setHideSkeletons(next)
await journeyApi.updatePreferences(current.id, { hide_skeletons: next })
}}
onClick={toggleSkeletons}
aria-label={skeletonLabel}
className={`w-[34px] h-[34px] rounded-full backdrop-blur flex items-center justify-center ${hideSkeletons ? 'bg-white/30' : 'bg-white/15 hover:bg-white/25'}`}
>
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<span className="absolute top-full mt-2 right-0 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
{skeletonLabel}
</span>
</div>
{canEditJourney && (
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
<button onClick={() => setShowSettings(true)} aria-label={t('journey.settings.title')} className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
)}
</div>
</div>
@@ -266,6 +292,7 @@ function JourneyDetailPageDesktop() {
</div>
</div>
</div>
)}
{/* Main content (was a 2-col grid with right-sidebar panels;
now single column inside the left feed — right pane is a
@@ -427,6 +454,41 @@ function JourneyDetailPageDesktop() {
/>
</div>
{/* Jump to the top (where adding lives) and back to the last entry
(where reading left off) — #1088. Centred over the feed and
clear of both edges: the right one belongs to the Add Entry
buttons, the left to the reorder arrows. Zero-height sticky box
so it rides the scroll without taking layout space, and each
half appears only when there is somewhere to go. */}
{!isMobile && (!feedEdge.atTop || !feedEdge.atBottom) && (
<div className="sticky bottom-0 z-20 h-0 pointer-events-none">
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-1.5 pointer-events-auto">
{!feedEdge.atTop && (
<button
onClick={() => scrollFeedTo('top')}
aria-label={t('journey.detail.jumpToTop')}
title={t('journey.detail.jumpToTop')}
className="w-8 h-8 rounded-full flex items-center justify-center shadow-md transition-transform hover:-translate-y-0.5"
style={{ background: 'var(--vg-surf)', border: '1px solid var(--vg-line)', color: 'var(--vg-ink)' }}
>
<ChevronUp size={16} strokeWidth={2.4} />
</button>
)}
{!feedEdge.atBottom && (
<button
onClick={() => scrollFeedTo('bottom')}
aria-label={t('journey.detail.jumpToLast')}
title={t('journey.detail.jumpToLast')}
className="w-8 h-8 rounded-full flex items-center justify-center shadow-md transition-transform hover:translate-y-0.5"
style={{ background: 'var(--vg-surf)', border: '1px solid var(--vg-line)', color: 'var(--vg-ink)' }}
>
<ChevronDown size={16} strokeWidth={2.4} />
</button>
)}
</div>
</div>
)}
</div>
</div>
@@ -105,6 +105,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
mapRef: { current: null }, fullMapRef: { current: null },
activeLocationId: null, handleMarkerClick: vi.fn(), handleLocationClick: vi.fn(),
mapEntries: [], sidebarMapItems: [], tripDates: new Set<string>(), isMobile: false,
feedEdge: { atTop: true, atBottom: true }, scrollFeedTo: vi.fn(),
loadJourney: vi.fn(), updateEntry: vi.fn(async () => {}), deleteEntry: vi.fn(async () => {}),
reorderEntries: vi.fn(async () => {}), uploadPhotos: vi.fn(async () => ({ succeeded: [], failed: [] })),
deletePhoto: vi.fn(async () => {}),
@@ -154,11 +155,13 @@ describe('JourneyDetailPage wiring', () => {
expect(hook.navigate).toHaveBeenCalledWith('/journey');
});
it('FE-JRN-DETWIRE-004: the download button lazy-loads the PDF book renderer', async () => {
it('FE-JRN-DETWIRE-004: the download button lazy-loads the PDF book renderer with the app language', async () => {
const { hook } = setup();
const buttons = screen.getAllByRole('button');
fireEvent.click(buttons[1]);
await waitFor(() => expect(mocks.downloadPdf).toHaveBeenCalledWith(hook.current));
fireEvent.click(screen.getByRole('button', { name: 'journey.pdf.saveAsPdf' }));
await waitFor(() => expect(mocks.downloadPdf).toHaveBeenCalledWith(
hook.current,
{ t: expect.any(Function), locale: 'en' },
));
});
it('FE-JRN-DETWIRE-005: the eye toggle flips hide_skeletons and persists the preference', async () => {
@@ -407,9 +410,9 @@ describe('JourneyDetailPage wiring', () => {
it('FE-JRN-DETWIRE-024: the floating mobile bar switches views, goes back and opens settings', () => {
const { hook } = setup({ isMobile: true });
// The hero keeps its own back button in the DOM (hidden via class) — the
// floating bar is the first one.
fireEvent.click(screen.getAllByRole('button', { name: 'journey.detail.backToJourney' })[0]);
// The hero is unmounted below 1024px, so the bar owns these labels alone.
expect(screen.queryByRole('heading', { name: 'Japan 2026' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'journey.detail.backToJourney' }));
expect(hook.navigate).toHaveBeenCalledWith('/journey');
fireEvent.click(screen.getAllByRole('button', { name: /journey.share.gallery/ })[0]);
@@ -421,9 +424,12 @@ describe('JourneyDetailPage wiring', () => {
expect(hook.setShowSettings).toHaveBeenCalledWith(true);
});
it('FE-JRN-DETWIRE-025: a non-owner sees a spacer instead of the mobile settings button', () => {
it('FE-JRN-DETWIRE-025: a non-owner gets the mobile bar without the settings button', () => {
setup({ isMobile: true, canEditJourney: false });
expect(screen.queryByRole('button', { name: 'journey.settings.title' })).not.toBeInTheDocument();
// Export and the suggestions switch are not owner-only.
expect(screen.getByRole('button', { name: 'journey.pdf.saveAsPdf' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'journey.skeletons.hide' })).toBeInTheDocument();
});
it('FE-JRN-DETWIRE-026: the fullscreen mobile entry view wires edit, delete and photos', () => {
@@ -458,4 +464,71 @@ describe('JourneyDetailPage wiring', () => {
setup({ current: buildDetail({ cover_image: 'covers/j.jpg' }) });
expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).toBeInTheDocument();
});
// ── Jump buttons (#1088) ──────────────────────────────────────────────────
it('FE-JRN-DETWIRE-029: a feed with nowhere to jump shows no buttons', () => {
setup({ feedEdge: { atTop: true, atBottom: true } });
expect(screen.queryByLabelText('journey.detail.jumpToTop')).not.toBeInTheDocument();
expect(screen.queryByLabelText('journey.detail.jumpToLast')).not.toBeInTheDocument();
});
it('FE-JRN-DETWIRE-030: each button appears only when that direction is available', () => {
const { unmount } = setup({ feedEdge: { atTop: false, atBottom: true } });
expect(screen.getByLabelText('journey.detail.jumpToTop')).toBeInTheDocument();
expect(screen.queryByLabelText('journey.detail.jumpToLast')).not.toBeInTheDocument();
unmount();
setup({ feedEdge: { atTop: true, atBottom: false } });
expect(screen.queryByLabelText('journey.detail.jumpToTop')).not.toBeInTheDocument();
expect(screen.getByLabelText('journey.detail.jumpToLast')).toBeInTheDocument();
});
it('FE-JRN-DETWIRE-031: the buttons scroll the feed to the matching end', () => {
const { hook } = setup({ feedEdge: { atTop: false, atBottom: false } });
fireEvent.click(screen.getByLabelText('journey.detail.jumpToTop'));
expect(hook.scrollFeedTo).toHaveBeenCalledWith('top');
fireEvent.click(screen.getByLabelText('journey.detail.jumpToLast'));
expect(hook.scrollFeedTo).toHaveBeenLastCalledWith('bottom');
});
it('FE-JRN-DETWIRE-032: the phone layout has its own chrome and gets no jump buttons', () => {
setup({ isMobile: true, feedEdge: { atTop: false, atBottom: false } });
expect(screen.queryByLabelText('journey.detail.jumpToTop')).not.toBeInTheDocument();
});
// ── Book export + suggestions below 1024px (#1848) ────────────────────────
it('FE-JRN-DETWIRE-033: the mobile bar exports the book with the app language', async () => {
const { hook } = setup({ isMobile: true });
fireEvent.click(screen.getByRole('button', { name: 'journey.pdf.saveAsPdf' }));
await waitFor(() => expect(mocks.downloadPdf).toHaveBeenCalledWith(
hook.current,
{ t: expect.any(Function), locale: 'en' },
));
});
it('FE-JRN-DETWIRE-034: the mobile bar toggle flips hide_skeletons and persists it', async () => {
const update = vi.spyOn(journeyApi, 'updatePreferences').mockResolvedValue({});
const { hook } = setup({ isMobile: true });
fireEvent.click(screen.getByRole('button', { name: 'journey.skeletons.hide' }));
expect(hook.setHideSkeletons).toHaveBeenCalledWith(true);
await waitFor(() => expect(update).toHaveBeenCalledWith(7, { hide_skeletons: true }));
});
it('FE-JRN-DETWIRE-035: with suggestions hidden the mobile toggle offers to show them again', () => {
setup({ isMobile: true, hideSkeletons: true });
expect(screen.getByRole('button', { name: 'journey.skeletons.show' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'journey.skeletons.hide' })).not.toBeInTheDocument();
});
it('FE-JRN-DETWIRE-036: both actions stay reachable on the mobile gallery tab', () => {
setup({ isMobile: true, view: 'gallery' });
expect(screen.getByRole('button', { name: 'journey.pdf.saveAsPdf' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'journey.skeletons.hide' })).toBeInTheDocument();
});
});
+3 -2
View File
@@ -459,6 +459,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) } : undefined}
onOpenTransit={(r) => setTransitJourney(r)}
onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined}
onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined}
onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined}
onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
@@ -683,7 +684,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
@@ -789,7 +790,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
)}
</div>
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} />
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} />
<TripFormModal
isOpen={showTripForm}
onClose={() => setShowTripForm(false)}
+58
View File
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest';
import {
A2_TO_A3,
countryStatus,
findBucketDuplicate,
isBucketDuplicateError,
isCountryVisible,
normalizeRegionName,
withCountryMarkedVisited,
@@ -210,3 +212,59 @@ describe('wishlistA3Codes', () => {
expect(result).toEqual(new Set(['JPN']));
});
});
// The client half of #1898 — the same identity the server enforces, checked
// before the request so the refusal arrives translated and instantly.
describe('findBucketDuplicate (#1898)', () => {
const candidate = { name: 'Japan', country_code: 'JP', target_date: null, lat: null, lng: null };
it('finds an entry with the same name, country and target date', () => {
const existing = bucketItem({ id: 5, name: 'Japan', country_code: 'JP' });
expect(findBucketDuplicate([existing], candidate)).toBe(existing);
});
it('lets the same place through for a different target date', () => {
const existing = bucketItem({ name: 'Japan', country_code: 'JP', target_date: '2027-05' });
expect(findBucketDuplicate([existing], candidate)).toBeUndefined();
expect(findBucketDuplicate([existing], { ...candidate, target_date: '2028-09' })).toBeUndefined();
expect(findBucketDuplicate([existing], { ...candidate, target_date: '2027-05' })).toBe(existing);
});
it('treats an empty string and null as the same "not set"', () => {
const undated = bucketItem({ name: 'Japan', country_code: 'JP', target_date: null });
expect(findBucketDuplicate([undated], { ...candidate, target_date: '' })).toBe(undated);
const blankCountry = bucketItem({ name: 'Japan', country_code: '' });
expect(findBucketDuplicate([blankCountry], { ...candidate, country_code: null })).toBe(blankCountry);
});
it('ignores surrounding whitespace and ASCII case in the name', () => {
const existing = bucketItem({ name: 'Kyoto', country_code: 'JP' });
expect(findBucketDuplicate([existing], { ...candidate, name: ' kyoto ' })).toBe(existing);
});
it('keeps non-ASCII case apart, matching SQLite lower()', () => {
// The server folds ASCII only, so folding more here would block a name the
// server would have accepted.
const existing = bucketItem({ name: 'Ísland', country_code: 'IS' });
expect(findBucketDuplicate([existing], { ...candidate, name: 'ísland', country_code: 'IS' })).toBeUndefined();
});
it('separates the same name in another country or at other coordinates', () => {
const existing = bucketItem({ name: 'Altstadt', country_code: 'DE', lat: 48.13, lng: 11.57 });
expect(findBucketDuplicate([existing], { ...candidate, name: 'Altstadt', country_code: 'AT', lat: 48.13, lng: 11.57 }))
.toBeUndefined();
expect(findBucketDuplicate([existing], { ...candidate, name: 'Altstadt', country_code: 'DE', lat: 50.94, lng: 6.96 }))
.toBeUndefined();
expect(findBucketDuplicate([existing], { ...candidate, name: 'Altstadt', country_code: 'DE', lat: 48.13, lng: 11.57 }))
.toBe(existing);
});
});
describe('isBucketDuplicateError (#1898)', () => {
it('recognises the 409 and nothing else', () => {
expect(isBucketDuplicateError({ response: { status: 409 } })).toBe(true);
expect(isBucketDuplicateError({ response: { status: 500 } })).toBe(false);
expect(isBucketDuplicateError(new Error('offline'))).toBe(false);
expect(isBucketDuplicateError(null)).toBe(false);
});
});
+38
View File
@@ -121,6 +121,44 @@ export function normalizeRegionName(name: string): string {
.trim()
}
// SQLite's lower() folds ASCII only, and the server's duplicate check runs the
// name through it — folding more here would block names the server accepts.
function foldBucketName(name: string): string {
return name.trim().replace(/[A-Z]/g, c => c.toLowerCase())
}
/**
* The entry this wish would duplicate, if any (#1898). Identity mirrors the
* server: name (trimmed, ASCII case-insensitive), country, target date and
* coordinates — so a different target date stays a separate, allowed entry.
* Empty string and null both mean "not set" because the forms send ''.
* The server answers 409 either way; this is what lets the UI say so up front,
* in the user's language and without losing what they typed.
*/
export function findBucketDuplicate(
items: BucketItem[],
candidate: Pick<BucketItem, 'name' | 'country_code' | 'target_date' | 'lat' | 'lng'>,
): BucketItem | undefined {
const name = foldBucketName(candidate.name)
const country = candidate.country_code || null
const targetDate = candidate.target_date || null
const lat = candidate.lat ?? null
const lng = candidate.lng ?? null
return items.find(
item =>
foldBucketName(item.name) === name &&
(item.country_code || null) === country &&
(item.target_date || null) === targetDate &&
(item.lat ?? null) === lat &&
(item.lng ?? null) === lng,
)
}
/** A bucket-list write the server refused as a duplicate (#1898). */
export function isBucketDuplicateError(err: unknown): boolean {
return (err as { response?: { status?: number } } | null)?.response?.status === 409
}
// Alpha-3 codes for bucket-list countries that aren't visited yet — the Atlas
// map renders these with a hatched fill so they read as "on the wishlist"
// instead of blending into the flat unvisited gray.
+37
View File
@@ -211,6 +211,7 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals();
delete window.__addToast;
});
describe('useAtlas', () => {
@@ -460,6 +461,42 @@ describe('useAtlas', () => {
expect(atlas.bucketForm.name).toBe('Lisbon');
});
it('FE-HOOK-ATLAS-036: an entry already on the list is not posted again (#1898)', async () => {
await mountAtlas({ bucket: [{ id: 3, name: 'Kyoto', lat: null, lng: null, country_code: null, notes: null, target_date: null }] });
const post = vi.fn();
server.use(http.post('/api/addons/atlas/bucket-list', () => { post(); return HttpResponse.json({ item: { id: 4 } }); }));
const addToast = vi.fn();
window.__addToast = addToast as unknown as typeof window.__addToast;
act(() => atlas.setBucketForm({ name: ' kyoto ', notes: '', lat: '', lng: '', target_date: '' }));
await act(async () => { await atlas.handleAddBucketItem(); });
expect(post).not.toHaveBeenCalled();
expect(atlas.bucketList).toHaveLength(1);
// The form keeps what was typed so another date can be picked right away.
expect(atlas.bucketForm.name).toBe(' kyoto ');
expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
// The same place for another date is a different wish and does go out.
act(() => atlas.setBucketForm({ name: 'Kyoto', notes: '', lat: '', lng: '', target_date: '2027-05' }));
await act(async () => { await atlas.handleAddBucketItem(); });
expect(post).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-ATLAS-037: a 409 from the server surfaces as a toast instead of silence (#1898)', async () => {
await mountAtlas();
server.use(http.post('/api/addons/atlas/bucket-list', () => HttpResponse.json({ error: 'Already on your bucket list' }, { status: 409 })));
const addToast = vi.fn();
window.__addToast = addToast as unknown as typeof window.__addToast;
act(() => atlas.setBucketForm({ name: 'Lisbon', notes: '', lat: '', lng: '', target_date: '' }));
await act(async () => { await atlas.handleAddBucketItem(); });
expect(atlas.bucketList).toHaveLength(0);
expect(atlas.bucketForm.name).toBe('Lisbon');
expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
});
it('FE-HOOK-ATLAS-017: deleting drops the item, a failure keeps it', async () => {
await mountAtlas({ bucket: [{ id: 3, name: 'Kyoto', lat: null, lng: null, country_code: 'JP', notes: null, target_date: null }] });
server.use(http.delete('/api/addons/atlas/bucket-list/:id', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
+20 -4
View File
@@ -5,8 +5,10 @@ import { useSettingsStore } from '../../store/settingsStore'
import apiClient, { mapsApi, pluginsApi, type PluginAtlasLayer } from '../../api/client'
import L from 'leaflet'
import type { GeoJsonFeatureCollection } from '../../types'
import { A2_TO_A3, countryStatus, isCountryVisible, normalizeRegionName, withCountryMarkedVisited, wishlistA3Codes, countryColor, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel'
import { A2_TO_A3, countryStatus, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, normalizeRegionName, withCountryMarkedVisited, wishlistA3Codes, countryColor, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel'
import { continentForCountry, type VisitStatus } from '@trek/shared'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
const PLANNED_KEY = 'trek_atlas_show_planned'
@@ -73,6 +75,7 @@ export function useAtlas() {
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const navigate = useNavigate()
const toast = useToast()
const resolveName = useCountryNames(language)
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
@@ -867,18 +870,31 @@ export function useAtlas() {
const handleAddBucketItem = async (): Promise<void> => {
if (!bucketForm.name.trim()) return
const hasCoords = !!(bucketForm.lat && bucketForm.lng)
const lat = hasCoords ? parseFloat(bucketForm.lat) : null
const lng = hasCoords ? parseFloat(bucketForm.lng) : null
const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null)
// #1898: this form never sends a country code, so the entry it would create
// is identified by name, date and coordinates alone. Keep the form filled so
// the user can just pick another date.
if (findBucketDuplicate(bucketList, { name: bucketForm.name, country_code: null, target_date: targetDate, lat, lng })) {
toast.error(t('atlas.bucketDuplicate'))
return
}
try {
const data: Record<string, unknown> = { name: bucketForm.name.trim() }
if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim()
if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) }
const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null)
if (hasCoords) { data.lat = lat; data.lng = lng }
if (targetDate) data.target_date = targetDate
const r = await apiClient.post('/addons/atlas/bucket-list', data)
setBucketList(prev => [r.data.item, ...prev])
setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' })
setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0)
setShowBucketAdd(false)
} catch { /* */ }
} catch (err) {
// The 409 used to vanish into a silent catch, leaving the button looking broken.
toast.error(isBucketDuplicateError(err) ? t('atlas.bucketDuplicate') : getApiErrorMessage(err, t('common.error')))
}
}
const handleDeleteBucketItem = async (id: number): Promise<void> => {
@@ -1,4 +1,4 @@
// FE-JRN-DETHOOK-001 to FE-JRN-DETHOOK-022
// FE-JRN-DETHOOK-001 to FE-JRN-DETHOOK-025
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
import { useLocation } from 'react-router';
import { http, HttpResponse } from 'msw';
@@ -335,4 +335,52 @@ describe('useJourneyDetail', () => {
await waitFor(() => expect(invalidateSize).toHaveBeenCalled());
expect(latest.view).toBe('gallery');
});
// ── Jump to top / to the last entry (#1088) ────────────────────────────────
/** jsdom has no layout, so hand the feed the numbers a real scrollport would report. */
function stubFeedScroll(metrics: { scrollTop: number; scrollHeight: number; clientHeight: number }) {
const feed = screen.getByTestId('feed');
for (const [prop, value] of Object.entries(metrics)) {
Object.defineProperty(feed, prop, { value, configurable: true });
}
return feed;
}
it('FE-JRN-DETHOOK-023: a feed short enough to read in one go offers no jump', async () => {
setup();
await waitFor(() => expect(latest.current).not.toBeNull());
const feed = stubFeedScroll({ scrollTop: 0, scrollHeight: 900, clientHeight: 800 });
fireEvent.scroll(feed);
await waitFor(() => expect(latest.feedEdge).toEqual({ atTop: true, atBottom: true }));
});
it('FE-JRN-DETHOOK-024: deep in a long journal both directions are offered', async () => {
setup();
await waitFor(() => expect(latest.current).not.toBeNull());
const feed = stubFeedScroll({ scrollTop: 3000, scrollHeight: 9000, clientHeight: 800 });
fireEvent.scroll(feed);
await waitFor(() => expect(latest.feedEdge).toEqual({ atTop: false, atBottom: false }));
// Back at the bottom only the way up is left.
Object.defineProperty(feed, 'scrollTop', { value: 8200, configurable: true });
fireEvent.scroll(feed);
await waitFor(() => expect(latest.feedEdge).toEqual({ atTop: false, atBottom: true }));
});
it('FE-JRN-DETHOOK-025: the jumps scroll the feed, not the window', async () => {
setup();
await waitFor(() => expect(latest.current).not.toBeNull());
const feed = stubFeedScroll({ scrollTop: 3000, scrollHeight: 9000, clientHeight: 800 });
const scrollTo = vi.fn();
Object.defineProperty(feed, 'scrollTo', { value: scrollTo, configurable: true });
latest.scrollFeedTo('top');
expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
latest.scrollFeedTo('bottom');
expect(scrollTo).toHaveBeenLastCalledWith({ top: 9000, behavior: 'smooth' });
});
});
@@ -9,6 +9,7 @@ import { DAY_COLORS } from '../../components/Journey/dayColors'
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../../components/Journey/JourneyMapAuto'
import { useToast } from '../../components/shared/Toast'
import { useIsMobile } from '../../hooks/useIsMobile'
import { lockBodyScroll } from '../../utils/bodyScrollLock'
import type { JourneyEntry } from '../../store/journeyStore'
import { createDraftJourneyEntry } from './JourneyDetailPage.helpers'
@@ -186,6 +187,58 @@ export function useJourneyDetail() {
return () => scrollCleanupRef.current?.()
}, [current?.entries, setupScrollSync])
// ── Jump to top / to the last entry (#1088) ───────────────────────────────
// A long journal is a long scroll: everything that adds to it — the entry
// button, the gallery upload — sits at the top, and where you were reading
// sits at the bottom. Two buttons beat two trips of the scroll wheel.
const [feedEdge, setFeedEdge] = useState<{ atTop: boolean; atBottom: boolean }>({ atTop: true, atBottom: true })
useEffect(() => {
const feed = feedRef.current
if (!feed) return
// 400px of travel before either button is worth offering; below that the
// scroll is short enough that a button is just clutter.
const THRESHOLD = 400
let frame: number | null = null
let pending = false
const measure = () => {
pending = false
const el = feedRef.current
if (!el) return
const scrolled = el.scrollTop
const remaining = el.scrollHeight - el.clientHeight - scrolled
setFeedEdge(prev => {
const next = { atTop: scrolled <= THRESHOLD, atBottom: remaining <= THRESHOLD }
return prev.atTop === next.atTop && prev.atBottom === next.atBottom ? prev : next
})
}
// The "already scheduled" flag is its own variable rather than the frame id:
// the id is only assigned once requestAnimationFrame returns, which is after
// the callback has already run whenever rAF fires synchronously.
const onScroll = () => {
if (pending) return
pending = true
frame = window.requestAnimationFrame(measure)
}
feed.addEventListener('scroll', onScroll, { passive: true })
measure()
// Entries load in batches, so the scrollable height grows after mount.
const ro = new ResizeObserver(measure)
ro.observe(feed)
if (feed.firstElementChild) ro.observe(feed.firstElementChild)
return () => {
feed.removeEventListener('scroll', onScroll)
ro.disconnect()
if (frame != null) window.cancelAnimationFrame(frame)
}
}, [current?.id, view])
const scrollFeedTo = useCallback((edge: 'top' | 'bottom') => {
const el = feedRef.current
if (!el) return
el.scrollTo({ top: edge === 'top' ? 0 : el.scrollHeight, behavior: 'smooth' })
}, [])
const handleMarkerClick = useCallback((entryId: string) => {
const el = document.querySelector(`[data-entry-id="${entryId}"]`)
if (!el) return
@@ -221,12 +274,11 @@ export function useJourneyDetail() {
}, [view])
// On desktop we run a two-pane layout where only the feed column scrolls;
// the body must not scroll underneath it. Restore on unmount.
// the body must not scroll underneath it. Goes through the shared counter so a
// modal opening on top of it cannot hand the page back early (#1809).
useEffect(() => {
if (isMobile) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
return lockBodyScroll()
}, [isMobile])
// Map only shows real journal entries — skeletons are trip-derived
@@ -303,6 +355,7 @@ export function useJourneyDetail() {
hideSkeletons, setHideSkeletons,
mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
}
}
@@ -241,7 +241,7 @@ export function useTripPlanner() {
// Public transit (#1065): open the TransportModal in its Automated mode, seed
// the search (change-route), and show the journey view for a saved entry.
const [transportModalAutomated, setTransportModalAutomated] = useState<boolean>(false)
const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null } | null>(null)
const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null; time?: string | null } | null>(null)
const [transitJourney, setTransitJourney] = useState<Reservation | null>(null)
// The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs
@@ -545,6 +545,7 @@ export function useTripPlanner() {
}
}
toast.success(t('trip.toast.placeUpdated'))
return { id: editingPlace.id }
} else {
const place = await tripActions.addPlace(tripId, data)
if (pendingFiles?.length > 0 && place?.id) {
@@ -562,6 +563,9 @@ export function useTripPlanner() {
await tripActions.deletePlace(tripId, capturedId)
})
}
// Handed back so the form can link an expense to a place that did not
// exist a moment ago (#1298), the same way the booking modals work.
return place?.id ? { id: place.id } : undefined
}
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
+31 -2
View File
@@ -1,4 +1,4 @@
// FE-STORE-COLLECTION-001 to FE-STORE-COLLECTION-062
// FE-STORE-COLLECTION-001 to FE-STORE-COLLECTION-063
//
// The store owns the optimistic updates (status, labels, reorder, bulk delete) and the
// reload chains that follow every mutation, so the API layer is mocked here and the
@@ -227,11 +227,17 @@ describe('collectionStore — setActive', () => {
it('FE-STORE-COLLECTION-010: setActive(id) delegates to loadCollection', async () => {
vi.mocked(collectionsApi.get).mockResolvedValue(detail({ places: [buildPlace()] }))
useCollectionStore.setState({ selectMode: true, selectedIds: [10], selectedPlaceId: 10, labelFilter: [3] })
await store().setActive(1)
expect(collectionsApi.get).toHaveBeenCalledWith(1)
expect(store().activeId).toBe(1)
// Switching lists still drops what belongs to the list we came from.
expect(store().selectMode).toBe(false)
expect(store().selectedIds).toEqual([])
expect(store().selectedPlaceId).toBeNull()
expect(store().labelFilter).toEqual([])
})
it('FE-STORE-COLLECTION-011: refreshActive() is a no-op without an active list', async () => {
@@ -241,13 +247,36 @@ describe('collectionStore — setActive', () => {
expect(collectionsApi.list).not.toHaveBeenCalled()
})
it('FE-STORE-COLLECTION-012: refreshActive() re-runs setActive for the current list', async () => {
it('FE-STORE-COLLECTION-012: refreshActive() re-reads the current list', async () => {
useCollectionStore.setState({ activeId: 1 })
await store().refreshActive()
expect(collectionsApi.get).toHaveBeenCalledWith(1)
})
it('FE-STORE-COLLECTION-062: refreshActive() keeps the selection and the label filter', async () => {
vi.mocked(collectionsApi.get).mockResolvedValue(detail({ places: [buildPlace({ id: 10 }), buildPlace({ id: 11 })] }))
useCollectionStore.setState({ activeId: 1, selectMode: true, selectedIds: [10, 11], selectedPlaceId: 11, labelFilter: [3] })
await store().refreshActive()
expect(store().selectMode).toBe(true)
expect(store().selectedIds).toEqual([10, 11])
expect(store().selectedPlaceId).toBe(11)
expect(store().labelFilter).toEqual([3])
})
it('FE-STORE-COLLECTION-063: refreshActive() drops selected places the reload no longer has', async () => {
vi.mocked(collectionsApi.get).mockResolvedValue(detail({ places: [buildPlace({ id: 10 })] }))
useCollectionStore.setState({ activeId: 1, selectMode: true, selectedIds: [10, 11], selectedPlaceId: 11 })
await store().refreshActive()
expect(store().selectedIds).toEqual([10])
expect(store().selectedPlaceId).toBeNull()
expect(store().selectMode).toBe(true)
})
})
describe('collectionStore — list CRUD', () => {
+54 -31
View File
@@ -94,6 +94,49 @@ interface CollectionState {
clearSelection: () => void
}
/**
* Load whatever `id` points at (a list, the "All saved" union, or nothing) into
* the store. Split out of setActive() because switching lists also has to drop
* the per-list UI state, while re-reading the list that is already open must not
* (#1921: every add from the still-open add-place dialog runs a refresh).
*/
async function loadActive(
set: (partial: Partial<CollectionState>) => void,
get: () => CollectionState,
id: ActiveCollectionId,
): Promise<void> {
if (id === null) {
set({ activeId: null, places: [], members: [], labels: [] })
return
}
if (id === ALL_SAVED) {
set({ activeId: ALL_SAVED, members: [], labels: [], placesLoading: true })
try {
// Client-side union of every list the user owns or co-owns (no server change).
// On first load the lists may not be fetched yet (loadAll still in flight),
// which would union nothing — make sure they're loaded first.
let lists = get().collections
if (lists.length === 0) { await get().loadAll(); lists = get().collections }
const results = await Promise.all(lists.map(l => collectionsApi.get(l.id).catch(() => null)))
const seen = new Set<number>()
const merged: CollectionPlace[] = []
for (const res of results) {
if (!res) continue
for (const p of res.places) {
if (seen.has(p.id)) continue
seen.add(p.id)
merged.push(p)
}
}
set({ places: merged })
} finally {
set({ placesLoading: false })
}
return
}
await get().loadCollection(id)
}
export const useCollectionStore = create<CollectionState>((set, get) => ({
collections: [],
activeId: null,
@@ -148,42 +191,22 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
setActive: async (id: ActiveCollectionId) => {
// Labels are per-collection, so their filter can't carry across lists.
set({ selectMode: false, selectedIds: [], selectedPlaceId: null, labelFilter: [] })
if (id === null) {
set({ activeId: null, places: [], members: [], labels: [] })
return
}
if (id === ALL_SAVED) {
set({ activeId: ALL_SAVED, members: [], labels: [], placesLoading: true })
try {
// Client-side union of every list the user owns or co-owns (no server change).
// On first load the lists may not be fetched yet (loadAll still in flight),
// which would union nothing — make sure they're loaded first.
let lists = get().collections
if (lists.length === 0) { await get().loadAll(); lists = get().collections }
const results = await Promise.all(lists.map(l => collectionsApi.get(l.id).catch(() => null)))
const seen = new Set<number>()
const merged: CollectionPlace[] = []
for (const res of results) {
if (!res) continue
for (const p of res.places) {
if (seen.has(p.id)) continue
seen.add(p.id)
merged.push(p)
}
}
set({ places: merged })
} finally {
set({ placesLoading: false })
}
return
}
await get().loadCollection(id)
await loadActive(set, get, id)
},
refreshActive: async () => {
const { activeId } = get()
if (activeId === null) return
await get().setActive(activeId)
// Same list, so select mode, the selection and the label filter stay put.
await loadActive(set, get, activeId)
// Only what the reload no longer contains can't stay selected.
const ids = new Set(get().places.map(p => p.id))
const { selectedIds, selectedPlaceId } = get()
const kept = selectedIds.filter(id => ids.has(id))
const keptPlaceId = selectedPlaceId != null && ids.has(selectedPlaceId) ? selectedPlaceId : null
if (kept.length !== selectedIds.length || keptPlaceId !== selectedPlaceId) {
set({ selectedIds: kept, selectedPlaceId: keptPlaceId })
}
},
createCollection: async (payload) => {
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { lockBodyScroll, bodyScrollLocks, resetBodyScrollLock } from './bodyScrollLock'
/**
* The lock only became load-bearing with #1809 (below 768px the document is the
* scroller), and the bug it has to rule out is an overlay clearing a lock that
* another overlay still holds.
*/
describe('bodyScrollLock', () => {
beforeEach(() => {
resetBodyScrollLock()
document.body.style.overflow = ''
})
it('FE-UTIL-SCROLLLOCK-001: locks the body and restores the previous value', () => {
document.body.style.overflow = 'auto'
const release = lockBodyScroll()
expect(document.body.style.overflow).toBe('hidden')
release()
expect(document.body.style.overflow).toBe('auto')
})
it('FE-UTIL-SCROLLLOCK-002: stacked overlays only unlock on the last release', () => {
const first = lockBodyScroll()
const second = lockBodyScroll()
expect(bodyScrollLocks()).toBe(2)
second()
expect(document.body.style.overflow).toBe('hidden')
first()
expect(document.body.style.overflow).toBe('')
expect(bodyScrollLocks()).toBe(0)
})
it('FE-UTIL-SCROLLLOCK-003: releasing twice leaves the other overlay lock intact', () => {
const first = lockBodyScroll()
const second = lockBodyScroll()
second()
second()
second()
expect(bodyScrollLocks()).toBe(1)
expect(document.body.style.overflow).toBe('hidden')
first()
expect(document.body.style.overflow).toBe('')
})
it('FE-UTIL-SCROLLLOCK-004: an unlock never sets a value the caller did not save', () => {
document.body.style.overflow = 'scroll'
const release = lockBodyScroll()
const nested = lockBodyScroll()
nested()
release()
expect(document.body.style.overflow).toBe('scroll')
})
})
+43
View File
@@ -0,0 +1,43 @@
/**
* The one body scroll lock every overlay in the app shares.
*
* Below 768px the document itself is the scroller now (#1809), so locking is no
* longer cosmetic: an overlay that resets `body.style.overflow` unconditionally
* really does unlock the page behind another overlay that is still open (a
* system notice re-running its effect used to do exactly that to an open
* sheet). Overlays stack, so the first lock remembers the original value and
* only the last release puts it back.
*/
let locks = 0
let savedOverflow = ''
/**
* Locks body scrolling and returns the matching release. Releasing twice is a
* no-op, so the return value can be used directly as an effect cleanup.
*/
export function lockBodyScroll(): () => void {
if (locks === 0) {
savedOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
}
locks += 1
let released = false
return () => {
if (released) return
released = true
locks = Math.max(0, locks - 1)
if (locks === 0) document.body.style.overflow = savedOverflow
}
}
/** How many locks are currently held. For tests and diagnostics. */
export function bodyScrollLocks(): number {
return locks
}
/** Test seam: the counter is module state and outlives a single test case. */
export function resetBodyScrollLock(): void {
locks = 0
savedOverflow = ''
}
@@ -5,6 +5,8 @@ import { render, screen, fireEvent } from '../../helpers/render';
import MBottomNav from '../../../src/mobile/components/MBottomNav';
import { useAddonStore } from '../../../src/store/addonStore';
import { usePluginStore } from '../../../src/store/pluginStore';
import { useSettingsStore } from '../../../src/store/settingsStore';
import { DEFAULT_APPEARANCE } from '@trek/shared';
function LocationEcho() {
const location = useLocation();
@@ -25,10 +27,31 @@ function seedAddons(ids: string[]) {
});
}
type MobileNavCfg = { bar: string[]; more: string[] } | null;
/** A dock split as the Appearance customizer persists it; null = un-customised. */
function seedMobileNav(cfg: MobileNavCfg) {
const { settings } = useSettingsStore.getState();
useSettingsStore.setState({
settings: {
...settings,
appearance: { ...DEFAULT_APPEARANCE, mobileNav: cfg ?? { bar: [], more: [] } },
},
});
}
/** [left group, centre slot, right group] of the dock, in DOM order. */
function dockSlots(container: HTMLElement) {
const dock = container.querySelector('nav') as HTMLElement;
const [left, centre, right] = Array.from(dock.children) as HTMLElement[];
return { count: dock.children.length, left, centre, right };
}
describe('MBottomNav', () => {
beforeEach(() => {
useAddonStore.setState({ addons: [], loaded: true });
usePluginStore.setState({ plugins: [], loaded: true });
seedMobileNav(null);
});
it('FE-MOB-NAV-001: renders the dashboard tab plus enabled global addons', () => {
@@ -180,4 +203,44 @@ describe('MBottomNav', () => {
expect(at()).toBe('/collections');
});
it('FE-MOB-NAV-018: vacay owns its centre slot, so the dock offers no create action (#1811)', () => {
seedAddons(['vacay', 'atlas']);
const { container } = render(<MBottomNav />, { initialEntries: ['/vacay'] });
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
// Nothing tappable in the middle at all: the screen's own FAB covers it, and
// until it mounts an empty slot beats a wrong action.
expect(dockSlots(container).centre).toBeEmptyDOMElement();
expect(screen.getByRole('button', { name: 'Vacay' })).toHaveAttribute('aria-current', 'page');
});
const DOCK_CONFIGS: [string, MobileNavCfg][] = [
['the built-in dock', null],
['vacay pinned alone', { bar: ['vacay'], more: ['atlas', 'journey', 'collections'] }],
['vacay demoted to More', { bar: ['journey', 'collections'], more: ['vacay', 'atlas'] }],
];
it.each(DOCK_CONFIGS)('FE-MOB-NAV-019: the empty vacay slot keeps the FAB geometry (%s)', (_label, cfg) => {
seedAddons(['vacay', 'atlas', 'journey', 'collections']);
seedMobileNav(cfg);
const dashboard = render(<MBottomNav />, { initialEntries: ['/dashboard'] });
const withFab = dockSlots(dashboard.container);
const groups = [withFab.left.children.length, withFab.right.children.length];
const fabClasses = withFab.centre.className.split(' ');
const slots = withFab.count;
dashboard.unmount();
const vacay = render(<MBottomNav />, { initialEntries: ['/vacay'] });
const placeholder = dockSlots(vacay.container);
expect(placeholder.count).toBe(slots);
// Same 56px box as MFab, so both tab groups stay symmetric around the centre.
['mx-2', 'h-14', 'w-14', 'flex-none'].forEach((cls) => {
expect(fabClasses).toContain(cls);
expect(placeholder.centre).toHaveClass(cls);
});
expect([placeholder.left.children.length, placeholder.right.children.length]).toEqual(groups);
});
});
@@ -57,4 +57,64 @@ describe('MobileShell', () => {
delete window.__addToast;
});
// #1809: iOS Safari only minimises its address bar when the document scrolls,
// so the phone shell must not sit a scroll container between the two.
it('FE-MOB-MSHELL-005: the phone shell owns no scroll container and grows with the document', () => {
const { container } = render(
<MobileShell isPhone><p>page body</p></MobileShell>,
{ initialEntries: ['/dashboard'] },
);
const root = container.querySelector('.m-root') as HTMLElement;
// svh, not dvh: dvh grows while the toolbar retracts.
expect(root.className).toContain('min-h-svh');
expect(root.className).not.toContain('h-dvh');
expect(root.querySelector(':scope > .overflow-y-auto')).toBeNull();
const content = screen.getByText('page body').parentElement as HTMLElement;
expect(content.className).toContain('flex-1');
expect(content.className).not.toContain('overflow');
});
it('FE-MOB-MSHELL-006: paints background and screen gradient on a viewport-sized layer', () => {
const { container } = render(
<MobileShell isPhone><p>page body</p></MobileShell>,
{ initialEntries: ['/dashboard'] },
);
const root = container.querySelector('.m-root') as HTMLElement;
const layer = root.querySelector('[aria-hidden="true"]') as HTMLElement;
expect(layer).toBeInTheDocument();
expect(layer.className).toContain('fixed');
expect(layer.className).toContain('inset-0');
expect(layer.className).toContain('var(--m-scr)');
expect(layer.className).toContain('var(--m-bg)');
expect(root.className).not.toContain('var(--m-bg)');
expect(layer.className).toContain('-z-10');
});
it('FE-MOB-MSHELL-006b: the content wrapper is not a stacking context', () => {
// A positioned wrapper with a z-index traps every fixed overlay a screen
// renders inside it: the vacay view/edit FAB (z-50) went underneath the dock
// (z-40) when this carried `relative z-10`. The screens rely on their own
// z-index competing with the chrome, so this has to stay unpositioned.
render(<MobileShell isPhone><p>page body</p></MobileShell>, { initialEntries: ['/dashboard'] });
const content = screen.getByText('page body').parentElement as HTMLElement;
expect(content.className).toContain('flex-1');
expect(content.className).not.toMatch(/(^|\s)(relative|absolute|fixed|sticky)(\s|$)/);
expect(content.className).not.toMatch(/z-\[?\d/);
});
it('FE-MOB-MSHELL-007: the desktop branch keeps its own scroller', () => {
render(
<MobileShell isPhone={false}><p>page body</p></MobileShell>,
{ initialEntries: ['/dashboard'] },
);
const content = screen.getByText('page body').parentElement as HTMLElement;
expect(content.className).toContain('overflow-y-auto');
expect(content.className).toContain('md:overflow-visible');
});
});
@@ -369,4 +369,15 @@ describe('MAdmin', () => {
expect(screen.getByText('7')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
});
it('FE-MOB-ADMIN-018: the long admin flow scrolls with the document', async () => {
// #1809: no height and no scroller of its own, otherwise iOS Safari never
// sees the root scroller move and keeps its address bar at full height.
const { container } = render(<MAdmin />);
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
const root = container.firstElementChild as HTMLElement;
expect(root.className).not.toContain('h-full');
expect(root.className).not.toContain('overflow-y-auto');
});
});
+12 -1
View File
@@ -5,7 +5,7 @@ import type { AtlasController } from '../../../../src/mobile/screens/atlas/atlas
import type { CountryDetail } from '../../../../src/pages/atlas/atlasModel';
import MAtlas from '../../../../src/mobile/screens/atlas/MAtlas';
// FE-MOB-ATLASSCR-001 to FE-MOB-ATLASSCR-019
// FE-MOB-ATLASSCR-001 to FE-MOB-ATLASSCR-020
const mocks = vi.hoisted(() => ({ atlas: {} as AtlasController }));
@@ -261,4 +261,15 @@ describe('MAtlas', () => {
// Japan has the later date but is only planned, so France leads and keeps "Visited".
expect(rows.map((r) => r.textContent)).toEqual(['FranceVisited', 'JapanPlanned']);
});
it('FE-MOB-ATLASSCR-020: the screen measures itself, not the shell', () => {
// #1809: the shell scrolls with the document now and hands down no definite
// height. A map on a percentage of an auto-height parent collapses to zero.
const { container, rerender } = render(<MAtlas />);
expect(container.firstElementChild).toHaveClass('h-dvh');
setAtlas({ loading: true });
rerender(<MAtlas />);
expect(container.firstElementChild).toHaveClass('h-dvh');
});
});
@@ -1,4 +1,4 @@
// FE-MOB-COLADD-001 to FE-MOB-COLADD-015
// FE-MOB-COLADD-001 to FE-MOB-COLADD-017
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
import { http, HttpResponse } from 'msw'
@@ -98,9 +98,10 @@ describe('MCollAddSheet', () => {
await user.click(hitRow)
expect(screen.getByPlaceholderText('common.name')).toHaveValue('Elbphilharmonie')
expect(screen.getByPlaceholderText('collections.addPlaceSearch')).toHaveValue('Elbphilharmonie')
// The result list collapses once a hit is taken, the address stays as a hint.
// The result list collapses once a hit is taken; the address lands in the
// editable field instead of the old read-only hint (#1870).
expect(screen.queryByText('COMMON.SEARCH')).not.toBeInTheDocument()
expect(screen.getByText('Platz der Deutschen Einheit 1')).toBeInTheDocument()
expect(screen.getByPlaceholderText('places.formAddressPlaceholder')).toHaveValue('Platz der Deutschen Einheit 1')
})
it('FE-MOB-COLADD-003: Enter in the search field runs the search and the panel can be dismissed', async () => {
@@ -322,4 +323,39 @@ describe('MCollAddSheet', () => {
await waitFor(() => expect(seen.body).toBeDefined())
expect(seen.body).toMatchObject({ collection_id: 1 })
})
// ── Typed address (#1870) ──────────────────────────────────────────────────
it('FE-MOB-COLADD-016: a hand-typed address is trimmed into the save request', async () => {
const user = userEvent.setup()
const seen = mockSave()
render(<MCollAddSheet {...baseProps()} />)
await user.type(screen.getByPlaceholderText('common.name'), 'Sternschanze')
await user.type(screen.getByPlaceholderText('places.formAddressPlaceholder'), ' Schanzenstr. 1 ')
await user.click(screen.getByRole('button', { name: /common.add/ }))
await waitFor(() => expect(seen.body).toBeDefined())
expect(seen.body).toMatchObject({ name: 'Sternschanze', address: 'Schanzenstr. 1' })
})
it('FE-MOB-COLADD-017: the address of a picked hit can be corrected before saving', async () => {
const user = userEvent.setup()
mockSearch()
const seen = mockSave()
render(<MCollAddSheet {...baseProps()} />)
await user.type(screen.getByPlaceholderText('collections.addPlaceSearch'), 'elbphil')
await user.click(screen.getByRole('button', { name: 'common.search' }))
await user.click(await screen.findByText('Elbphilharmonie'))
const addressInput = screen.getByPlaceholderText('places.formAddressPlaceholder')
await user.clear(addressInput)
await user.type(addressInput, 'Platz der Deutschen Einheit 4')
await user.click(screen.getByRole('button', { name: /common.add/ }))
await waitFor(() => expect(seen.body).toBeDefined())
// The corrected address is stored, the rest of the maps provenance survives.
expect(seen.body).toMatchObject({ address: 'Platz der Deutschen Einheit 4', google_place_id: 'gp-1' })
})
})
@@ -8,7 +8,7 @@ import type { Category } from '../../../../src/types'
import { resetAllStores } from '../../../helpers/store'
import { useTranslation } from '../../../../src/i18n'
// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-033
// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-037
// react-markdown ships ESM-only chunks jsdom chokes on; the sheet only needs
// the raw description text to reach the renderer.
@@ -177,8 +177,9 @@ describe('MCollPlaceSheet', () => {
expect(screen.getByDisplayValue('https://narisawa.example/menu')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Must see' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Rainy day' })).toHaveAttribute('aria-pressed', 'false')
// The read-only address disappears while editing.
// The read-only address line gives way to the editable field (#1870).
expect(screen.queryByText('2-6-15 Minami-Aoyama')).not.toBeInTheDocument()
expect(screen.getByDisplayValue('2-6-15 Minami-Aoyama')).toBeInTheDocument()
})
it('FE-MOB-CPLSH-013: saving sends the trimmed name, labels, category and normalised links', async () => {
@@ -191,6 +192,8 @@ describe('MCollPlaceSheet', () => {
await waitFor(() => expect(onSave).toHaveBeenCalledWith({
name: 'Narisawa Tokyo',
// Untouched here, but the address rides along on every save (#1870).
address: '2-6-15 Minami-Aoyama',
description: 'Two stars, book early',
links: [
{ label: 'Menu', url: 'https://narisawa.example/menu' },
@@ -378,4 +381,41 @@ describe('MCollPlaceSheet', () => {
rerender(<Harness {...props} place={null} />)
expect(screen.getByText('Narisawa')).toBeInTheDocument()
})
// ── Editable address (#1870) ───────────────────────────────────────────────
it('FE-MOB-CPLSH-034: the edit form seeds the address field from the place', () => {
setup()
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('2-6-15 Minami-Aoyama')
})
it('FE-MOB-CPLSH-035: a corrected address is trimmed into the patch', async () => {
const { onSave } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.change(screen.getByPlaceholderText('Street, City, Country'), { target: { value: ' 3-1-1 Kita-Aoyama ' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ address: '3-1-1 Kita-Aoyama' })))
})
it('FE-MOB-CPLSH-036: an emptied address is sent as null', async () => {
const { onSave } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.change(screen.getByPlaceholderText('Street, City, Country'), { target: { value: ' ' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ address: null })))
})
it('FE-MOB-CPLSH-037: cancel restores the stored address', () => {
setup()
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.change(screen.getByPlaceholderText('Street, City, Country'), { target: { value: 'Elsewhere' } })
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByText('2-6-15 Minami-Aoyama')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('2-6-15 Minami-Aoyama')
})
})
@@ -239,7 +239,7 @@ describe('MCollections', () => {
expect(screen.queryByRole('button', { name: 'collections.editListTitle' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collections.share.title' })).not.toBeInTheDocument()
// No per-list labels on the union.
expect(screen.queryByRole('button', { name: /collections.linkLabel/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /collections.labels.title/ })).not.toBeInTheDocument()
})
it('FE-MOB-COLSCR-005: without an active list the generic page title is used', () => {
@@ -365,7 +365,7 @@ describe('MCollections', () => {
fireEvent.click(chips[1])
expect(screen.getByText('collections.labels.empty')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /collections.linkLabel/ }))
fireEvent.click(screen.getByRole('button', { name: /collections.labels.title/ }))
expect(fn('setShowLabelManager')).toHaveBeenCalledWith(true)
})
@@ -443,19 +443,18 @@ describe('MDashboard', () => {
expect(navigate).toHaveBeenNthCalledWith(2, '/trips/17');
});
it('FE-MOB-DASH-031: the brand tile scrolls the shell content back to the top', () => {
// The screen scrolls inside the shell's container, not the window.
const scroller = document.createElement('div');
scroller.className = 'overflow-y-auto';
it('FE-MOB-DASH-031: the brand tile scrolls the page back to the top', () => {
// Since #1809 the document itself is the scroller on a phone, so there is no
// shell container left to walk up to.
const scrollTo = vi.fn();
scroller.scrollTo = scrollTo;
document.body.appendChild(scroller);
render(<MDashboard />, { container: scroller });
const original = window.scrollTo;
window.scrollTo = scrollTo as unknown as typeof window.scrollTo;
render(<MDashboard />);
fireEvent.click(screen.getByRole('button', { name: 'TREK' }));
expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
scroller.remove();
window.scrollTo = original;
});
it('FE-MOB-DASH-032: the user menu backdrop closes it again', () => {
@@ -1,4 +1,4 @@
// FE-MOB-JLIST-001 to FE-MOB-JLIST-012
// FE-MOB-JLIST-001 to FE-MOB-JLIST-013
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '../../../helpers/render';
import MJourney from '../../../../src/mobile/screens/journey/MJourney';
@@ -149,4 +149,14 @@ describe('MJourney', () => {
fireEvent.click(cancels[cancels.length - 1]);
expect(hook.setShowCreate).toHaveBeenCalledWith(false);
});
it('FE-MOB-JLIST-013: the feed keeps its own scroller on a self-measured screen', () => {
// #1809: the shell hands down no definite height any more, so the screen
// sets one and the feed keeps scrolling inside it.
mocks.journey = buildHook();
const { container } = render(<MJourney />);
expect(container.firstElementChild).toHaveClass('h-dvh');
expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
// FE-MOB-JDET-001 to FE-MOB-JDET-031
// FE-MOB-JDET-001 to FE-MOB-JDET-037
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '../../../helpers/render';
import MJourneyDetail from '../../../../src/mobile/screens/journey/MJourneyDetail';
@@ -12,12 +12,17 @@ const mocks = vi.hoisted(() => ({
focusMarker: vi.fn(),
highlightMarker: vi.fn(),
captured: {} as Record<string, Record<string, unknown>>,
downloadPdf: vi.fn(),
}));
vi.mock('../../../../src/pages/journeyDetail/useJourneyDetail', () => ({
useJourneyDetail: () => mocks.detail,
}));
vi.mock('../../../../src/components/PDF/JourneyBookPDF', () => ({
downloadJourneyBookPDF: mocks.downloadPdf,
}));
vi.mock('../../../../src/components/Journey/JourneyMapAuto', async () => {
const React = await import('react');
const Comp = React.forwardRef((props: Record<string, unknown>, ref: React.Ref<unknown>) => {
@@ -151,6 +156,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
navigate: vi.fn(),
toast,
t: (key: string) => key,
locale: 'en-US',
current,
loading: false,
canEditEntries: true,
@@ -168,6 +174,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
showSettings: false,
setShowSettings: vi.fn(),
hideSkeletons: false,
setHideSkeletons: vi.fn(),
sidebarMapItems: (current?.entries ?? []).map(e => ({ id: String(e.id), lat: 1, lng: 2, title: e.title ?? '' })),
loadJourney: vi.fn(),
updateEntry: vi.fn(async () => {}),
@@ -462,14 +469,16 @@ describe('MJourneyDetail', () => {
expect(hook.loadJourney).toHaveBeenCalledWith(12);
});
it('FE-MOB-JDET-026: the settings button is hidden for a non-owner and shown for the owner', () => {
it('FE-MOB-JDET-026: the overflow sheet opens the settings for an owner only', () => {
const { hook, unmount } = setup();
fireEvent.click(screen.getByRole('button', { name: 'journey.settings.title' }));
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
fireEvent.click(screen.getByText('journey.settings.title'));
expect(hook.setShowSettings).toHaveBeenCalledWith(true);
unmount();
setup({ canEditJourney: false });
expect(screen.queryByRole('button', { name: 'journey.settings.title' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
expect(screen.queryByText('journey.settings.title')).not.toBeInTheDocument();
});
it('FE-MOB-JDET-027: the lightbox renders the mapped photos and closes back into the hook', () => {
@@ -556,4 +565,79 @@ describe('MJourneyDetail', () => {
(mocks.captured.picker.onClose as () => void)();
await waitFor(() => expect(screen.queryByTestId('provider-picker')).not.toBeInTheDocument());
});
// ── Book export + suggestions on the phone (#1848) ────────────────────────
it('FE-MOB-JDET-032: the overflow sheet exports the book in the app language', async () => {
const { hook } = setup();
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
fireEvent.click(screen.getByText('journey.pdf.saveAsPdf'));
await waitFor(() => expect(mocks.downloadPdf).toHaveBeenCalledWith(
hook.current,
{ t: expect.any(Function), locale: 'en-US' },
));
await waitFor(() => expect(screen.queryByText('journey.pdf.saveAsPdf')).not.toBeInTheDocument());
});
it('FE-MOB-JDET-033: the suggestions switch flips hide_skeletons and persists it', async () => {
const update = vi.spyOn(journeyApi, 'updatePreferences').mockResolvedValue({});
const { hook } = setup();
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
const toggle = screen.getByRole('switch', { name: 'journey.skeletons.hide' });
expect(toggle).toHaveAttribute('aria-checked', 'false');
fireEvent.click(toggle);
expect(hook.setHideSkeletons).toHaveBeenCalledWith(true);
await waitFor(() => expect(update).toHaveBeenCalledWith(12, { hide_skeletons: true }));
});
it('FE-MOB-JDET-034: a failing preference write leaves the local flip standing', async () => {
vi.spyOn(journeyApi, 'updatePreferences').mockRejectedValue(new Error('offline'));
const { hook } = setup({ hideSkeletons: true });
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
const toggle = screen.getByRole('switch', { name: 'journey.skeletons.hide' });
expect(toggle).toHaveAttribute('aria-checked', 'true');
fireEvent.click(toggle);
await waitFor(() => expect(hook.setHideSkeletons).toHaveBeenCalledWith(false));
expect(toast.error).not.toHaveBeenCalled();
});
// ── External photos in the phone entry editor (#1808) ─────────────────────
it('FE-MOB-JDET-035: the entry sheet gets the provider context and writes the picked photos', async () => {
const trips = [{ trip_id: 3, added_at: 0, title: 'Tokyo', start_date: '2026-05-01', end_date: '2026-05-04', cover_image: null, currency: 'EUR', place_count: 2 }];
const addProvider = vi.spyOn(journeyApi, 'addProviderPhotos').mockResolvedValue({ added: 1 });
setup({ current: buildDetail({ trips }), editingEntry: buildEntry({ id: 4 }) });
expect(mocks.captured.entrySheet.userId).toBe(5);
expect(mocks.captured.entrySheet.trips).toEqual(trips);
const onAdd = mocks.captured.entrySheet.onAddProviderPhotos as (id: number, g: Record<string, unknown>) => Promise<void>;
await onAdd(4, { provider: 'immich', assetIds: ['a1'], passphrase: 'pw', mediaTypes: ['image'] });
expect(addProvider).toHaveBeenCalledWith(4, 'immich', ['a1'], undefined, 'pw', ['image']);
});
it('FE-MOB-JDET-036: a retried save updates the entry that was already created', async () => {
const { hook } = setup({ editingEntry: buildEntry({ id: 0, title: null }) });
const onSave = mocks.captured.entrySheet.onSave as (d: Record<string, unknown>, id?: number) => Promise<number>;
await expect(onSave({ title: 'Fresh' })).resolves.toBe(77);
expect(createEntry).toHaveBeenCalledTimes(1);
// Second attempt hands back the id from the first one — no second create.
await expect(onSave({ title: 'Fresh' }, 77)).resolves.toBe(77);
expect(createEntry).toHaveBeenCalledTimes(1);
expect(hook.updateEntry).toHaveBeenCalledWith(77, { title: 'Fresh' });
});
it('FE-MOB-JDET-037: the screen measures itself, not the shell', () => {
// #1809: the shell scrolls with the document now and hands down no definite
// height. A map on a percentage of an auto-height parent collapses to zero.
const { container } = setup();
expect(container.firstElementChild).toHaveClass('h-dvh');
});
});
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import userEvent from '@testing-library/user-event';
import { mapsApi, weatherApi } from '../../../../src/api/client';
import { useAddonStore } from '../../../../src/store/addonStore';
import MJourneyEntrySheet from '../../../../src/mobile/screens/journey/MJourneyEntrySheet';
import type { GalleryPhoto, JourneyEntry, JourneyPhoto } from '../../../../src/store/journeyStore';
import type { ResilientResult, UploadProgress } from '../../../../src/utils/uploadQueue';
@@ -71,7 +72,9 @@ describe('MJourneyEntrySheet quick capture', () => {
location_lat: 1.3521,
location_lng: 103.8198,
weather: 'rainy',
})
}),
// A new entry has nothing persisted yet, so no id to retry against (#1808).
undefined,
)
);
expect(props.onDone).toHaveBeenCalled();
@@ -133,7 +136,8 @@ describe('MJourneyEntrySheet quick capture', () => {
entry_time: '09:07',
location_lat: null,
location_lng: null,
})
}),
undefined,
)
);
expect(props.onDone).toHaveBeenCalled();
@@ -220,6 +224,7 @@ interface MountOptions {
quickCapture?: boolean;
readOnly?: boolean;
withDelete?: boolean;
withProviderHook?: boolean;
upload?: UploadFn;
}
@@ -232,22 +237,26 @@ function mountSheet(sheetEntry: JourneyEntry, opts: MountOptions = {}) {
const onClose = vi.fn();
const onDone = vi.fn();
const onDelete = vi.fn();
const onSave = vi.fn(async (_data: Record<string, unknown>) => 42);
const onSave = vi.fn(async (_data: Record<string, unknown>, _existingEntryId?: number) => 42);
const onUploadPhotos = vi.fn(opts.upload ?? defaultUpload);
const onAddProviderPhotos = vi.fn(async (_entryId: number, _group: Record<string, unknown>) => {});
const view = render(
<MJourneyEntrySheet
entry={sheetEntry}
galleryPhotos={opts.galleryPhotos ?? []}
quickCapture={opts.quickCapture ?? false}
readOnly={opts.readOnly ?? false}
userId={42}
trips={[]}
onClose={onClose}
onSave={onSave}
onUploadPhotos={onUploadPhotos}
onAddProviderPhotos={opts.withProviderHook === false ? undefined : onAddProviderPhotos}
onDelete={opts.withDelete ? onDelete : undefined}
onDone={onDone}
/>
);
return { ...view, onClose, onDone, onDelete, onSave, onUploadPhotos };
return { ...view, onClose, onDone, onDelete, onSave, onUploadPhotos, onAddProviderPhotos };
}
const multiFileInput = () => document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
@@ -362,7 +371,7 @@ describe('MJourneyEntrySheet full editor', () => {
tags: ['food', 'city'],
pros_cons: { pros: ['Gelato'], cons: ['Crowds'] },
type: undefined,
});
}, undefined);
});
it('FE-MOB-JENTRY-006: removes a pro and a con row again', async () => {
@@ -987,3 +996,153 @@ describe('MJourneyEntrySheet quick capture edge cases', () => {
expect(await screen.findByText('Location access was denied. Allow it in your browser settings and try again.')).toBeInTheDocument();
});
});
// FE-MOB-JENTRY-041 to FE-MOB-JENTRY-047 — external photo providers in the phone
// editor (#1808), mirroring the desktop EntryEditor mechanics.
const immichAddon = { id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true };
function seedAddons(addons: Array<Record<string, unknown>>) {
useAddonStore.setState({ addons: addons as never, loaded: true });
}
function useConnectedImmich() {
server.use(
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({
assets: [{ id: 'asset-1', takenAt: '2026-03-15T09:00:00.000Z', mediaType: 'image' }],
hasMore: false,
})),
);
}
describe('MJourneyEntrySheet external photos', () => {
beforeEach(() => {
toastSpy.mockClear();
window.__addToast = toastSpy;
});
afterEach(() => {
delete window.__addToast;
useAddonStore.setState({ addons: [], loaded: false });
});
it('FE-MOB-JENTRY-041: hides the button while no provider is connected', async () => {
let probes = 0;
seedAddons([immichAddon]);
server.use(http.get('/api/integrations/memories/immich/status', () => {
probes += 1;
return HttpResponse.json({ connected: false });
}));
mountSheet(buildEntry({ id: 5 }));
await waitFor(() => expect(probes).toBe(1));
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument();
});
it('FE-MOB-JENTRY-042: opens the embedded picker for a connected provider', async () => {
seedAddons([immichAddon, { id: 'budget', name: 'Budget', type: 'feature', icon: 'wallet', enabled: true }]);
useConnectedImmich();
const user = userEvent.setup();
mountSheet(buildEntry({ id: 5, location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' }));
await user.click(await screen.findByRole('button', { name: 'External photos' }));
expect(await screen.findByTestId('journey-provider-picker-embedded')).toBeInTheDocument();
expect(screen.getByText('Nearby photos first · Rome')).toBeInTheDocument();
// A single provider needs no chip row.
expect(screen.queryByTestId('journey-external-provider-immich')).not.toBeInTheDocument();
});
it('FE-MOB-JENTRY-043: stays out of the quick capture sheet', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
Object.defineProperty(navigator, 'geolocation', { configurable: true, value: undefined });
mountSheet(buildEntry(), { quickCapture: true });
expect(await screen.findByRole('button', { name: 'Gallery' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument();
});
it('FE-MOB-JENTRY-044: queues a picked photo and sends it after the save', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
const user = userEvent.setup();
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry({ id: 5 }));
await user.click(await screen.findByRole('button', { name: 'External photos' }));
await user.click(await screen.findByAltText(''));
await user.click(screen.getByRole('button', { name: 'Add (1)' }));
const queued = await screen.findByRole('button', { name: /1 queued · Clear/ });
expect(queued).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(onAddProviderPhotos).toHaveBeenCalledWith(
42,
expect.objectContaining({ provider: 'immich', assetIds: ['asset-1'] }),
));
expect(onSave).toHaveBeenCalledTimes(1);
expect(onDone).toHaveBeenCalledTimes(1);
});
it('FE-MOB-JENTRY-045: drops the queue again from the panel header', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
const user = userEvent.setup();
const { onAddProviderPhotos } = mountSheet(buildEntry({ id: 5 }));
await user.click(await screen.findByRole('button', { name: 'External photos' }));
await user.click(await screen.findByAltText(''));
await user.click(screen.getByRole('button', { name: 'Add (1)' }));
await user.click(await screen.findByRole('button', { name: /1 queued · Clear/ }));
expect(screen.queryByRole('button', { name: /queued/ })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(onAddProviderPhotos).not.toHaveBeenCalled());
});
it('FE-MOB-JENTRY-046: a failed provider add keeps the queue and does not create a second entry', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
const user = userEvent.setup();
// A brand new entry: the first save creates it, the provider write then fails.
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry());
onAddProviderPhotos.mockRejectedValueOnce(new Error('immich down'));
await user.click(await screen.findByRole('button', { name: 'External photos' }));
await user.click(await screen.findByAltText(''));
await user.click(screen.getByRole('button', { name: 'Add (1)' }));
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(toastSpy).toHaveBeenCalledWith('1 photo groups failed — save again to retry', 'error', undefined)
);
expect(onDone).not.toHaveBeenCalled();
expect(onSave.mock.calls[0][1]).toBeUndefined();
// Retry: the sheet hands back the id it already persisted, so the caller
// updates that entry instead of creating a second one.
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledTimes(2);
expect(onSave.mock.calls[1][1]).toBe(42);
expect(onAddProviderPhotos).toHaveBeenCalledTimes(2);
});
it('FE-MOB-JENTRY-047: a skeleton with only provider photos is promoted to an entry', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
const user = userEvent.setup();
const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton', title: 'Venice' }));
await user.click(await screen.findByRole('button', { name: 'External photos' }));
await user.click(await screen.findByAltText(''));
await user.click(screen.getByRole('button', { name: 'Add (1)' }));
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0]).toMatchObject({ type: 'entry' });
});
});
@@ -3,21 +3,34 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildSettings } from '../../../helpers/factories';
import { buildAdmin, buildSettings, buildUser } from '../../../helpers/factories';
import { useSettingsStore } from '../../../../src/store/settingsStore';
import { useAuthStore } from '../../../../src/store/authStore';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import type { Settings } from '../../../../src/types';
import MLlmConnectionSection from '../../../../src/mobile/screens/settings/MLlmConnectionSection';
let loadSettings = vi.fn().mockResolvedValue(undefined);
function seedLlm(over: Partial<Settings> = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) {
loadSettings = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, {
settings: buildSettings({ language: 'en', ...over }),
isLoaded: true,
updateSettings,
loadSettings,
});
return updateSettings;
}
function seedRole(role: 'user' | 'admin') {
seedStore(useAuthStore, {
user: role === 'admin' ? buildAdmin() : buildUser(),
isAuthenticated: true,
isLoading: false,
});
}
function renderSection() {
return render(
<>
@@ -30,30 +43,29 @@ function renderSection() {
describe('MLlmConnectionSection', () => {
beforeEach(() => {
resetAllStores();
seedRole('user');
seedLlm();
});
it('FE-MOB-SETLLM-001: defaults to the local provider with a base URL and no key field', () => {
it('FE-MOB-SETLLM-001: defaults to OpenAI with a key field and no endpoint of its own', () => {
renderSection();
expect(screen.getByText('AI parsing')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument();
expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument();
expect(screen.queryByText('Stored encrypted. Leave blank to keep the current key.')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
expect(screen.getByText('Stored encrypted. Leave blank to keep the current key.')).toBeInTheDocument();
// The endpoint is instance configuration since #1772, so it has no row here.
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
expect(
screen.getByText('A self-hosted (Ollama) endpoint is set up once for the whole instance in the admin settings. You can still use your own OpenAI or Anthropic key here.'),
).toBeInTheDocument();
});
it('FE-MOB-SETLLM-002: hydrates provider, model, base URL and the multimodal switch', () => {
seedLlm({
llm_provider: 'openai',
llm_model: 'gpt-4o-mini',
llm_base_url: 'https://api.openai.com/v1',
llm_multimodal: true,
});
it('FE-MOB-SETLLM-002: hydrates provider, model and the multimodal switch', () => {
seedLlm({ llm_provider: 'openai', llm_model: 'gpt-4o-mini', llm_multimodal: true });
renderSection();
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
expect(screen.getByDisplayValue('gpt-4o-mini')).toBeInTheDocument();
expect(screen.getByDisplayValue('https://api.openai.com/v1')).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Send documents as images' })).toHaveAttribute('aria-checked', 'true');
});
@@ -64,7 +76,7 @@ describe('MLlmConnectionSection', () => {
});
renderSection();
expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save/ })).toBeDisabled();
});
@@ -82,41 +94,39 @@ describe('MLlmConnectionSection', () => {
expect(screen.getByPlaceholderText('API key')).toHaveValue('');
});
it('FE-MOB-SETLLM-006: picking Anthropic hides the base URL and reveals the key field', async () => {
it('FE-MOB-SETLLM-006: the key row stays through a provider change, since both are hosted', async () => {
const user = userEvent.setup();
renderSection();
await user.click(screen.getByRole('button', { name: /Local \(Ollama\)/ }));
await user.click(screen.getByRole('button', { name: /OpenAI/ }));
await user.click(await screen.findByRole('button', { name: 'Anthropic' }));
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
expect(screen.getByText('Stored encrypted. Leave blank to keep the current key.')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
});
it('FE-MOB-SETLLM-007: picking OpenAI keeps the base URL and adds the key field', async () => {
it('FE-MOB-SETLLM-007: the picker offers exactly the two hosted providers', async () => {
const user = userEvent.setup();
renderSection();
await user.click(screen.getByRole('button', { name: /Local \(Ollama\)/ }));
await user.click(await screen.findByRole('button', { name: 'OpenAI' }));
await user.click(screen.getByRole('button', { name: /OpenAI/ }));
expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument();
expect(screen.getByPlaceholderText('API key')).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Anthropic' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Local \(Ollama\)/ })).not.toBeInTheDocument();
});
it('FE-MOB-SETLLM-008: saving trims the inputs and omits the key when none was typed', async () => {
const user = userEvent.setup();
const updateSettings = seedLlm({ llm_model: '', llm_base_url: '' });
const updateSettings = seedLlm({ llm_model: '' });
renderSection();
await user.type(screen.getByPlaceholderText('qwen3:8b'), ' qwen3:8b ');
await user.type(screen.getByPlaceholderText('http://localhost:11434'), ' http://ollama.local ');
await user.type(screen.getByPlaceholderText('qwen3:8b'), ' gpt-4o-mini ');
await user.click(screen.getByRole('button', { name: /Save/ }));
expect(updateSettings).toHaveBeenCalledWith({
llm_provider: 'local',
llm_model: 'qwen3:8b',
llm_base_url: 'http://ollama.local',
llm_provider: 'openai',
llm_model: 'gpt-4o-mini',
llm_base_url: '',
llm_multimodal: false,
});
await screen.findByText('AI settings saved');
@@ -134,7 +144,7 @@ describe('MLlmConnectionSection', () => {
await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toHaveValue(''));
});
it('FE-MOB-SETLLM-010: a non-local provider never persists a base URL', async () => {
it('FE-MOB-SETLLM-010: a stored base URL is never resent', async () => {
const user = userEvent.setup();
const updateSettings = seedLlm({ llm_provider: 'anthropic', llm_base_url: 'http://leftover' });
renderSection();
@@ -162,4 +172,54 @@ describe('MLlmConnectionSection', () => {
await user.click(screen.getByRole('button', { name: /Save/ }));
await screen.findByText('Could not save AI settings');
});
it('FE-MOB-SETLLM-013: a stored local provider falls back to OpenAI without saving anything', () => {
const updateSettings = seedLlm({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
renderSection();
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
expect(screen.queryByDisplayValue('http://192.168.1.5:11434')).not.toBeInTheDocument();
expect(updateSettings).not.toHaveBeenCalled();
});
it('FE-MOB-SETLLM-014: saving clears the leftover base URL instead of resending it', async () => {
const user = userEvent.setup();
const updateSettings = seedLlm({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
renderSection();
await user.click(screen.getByRole('button', { name: /Save/ }));
expect(updateSettings).toHaveBeenCalledWith({
llm_provider: 'openai',
llm_model: 'nuextract',
llm_base_url: '',
llm_multimodal: false,
});
});
it('FE-MOB-SETLLM-015: a refused save pulls the stored settings back in', async () => {
const user = userEvent.setup();
seedLlm({ llm_provider: 'anthropic' }, vi.fn().mockRejectedValue(new Error('Admin access required')));
renderSection();
await user.click(screen.getByRole('button', { name: /Save/ }));
await screen.findByText('Could not save AI settings');
expect(loadSettings).toHaveBeenCalled();
});
// The point of #1772: an instance has one endpoint, so this surface is the same
// for the person who runs it. The admin sets it on the addon, not here.
it('FE-MOB-SETLLM-016: an admin sees the same two providers and no endpoint row', async () => {
const user = userEvent.setup();
seedRole('admin');
seedLlm({ llm_provider: 'local', llm_model: 'qwen3:8b', llm_base_url: 'http://localhost:11434' });
renderSection();
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /OpenAI/ }));
expect(screen.queryByRole('button', { name: /Local \(Ollama\)/ })).not.toBeInTheDocument();
});
});

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