fix: eleven reported bugs across atlas, budget, collections, journey, vacay and the LLM settings (#1923)

* fix(mcp): accept per-leg times on create_transport and update_transport

metadata was pinned to Record<string, string>, so a legs array could not get
through and a multi-leg flight built over MCP lost every intermediate time. The
booking ended up with correct endpoints and no legs, which every reader then
treats as a single segment.

legs is now a real, typed parameter with its contract in @trek/shared, shaped
exactly the way the planner form and the importers write metadata.legs. The
folding checks what the readers depend on: one leg fewer than endpoints (the
AirTrail dedupe builds its signatures on that), day ids that belong to the trip,
airport codes that agree with their endpoint, and the times, where a stopover
endpoint carries the departure and the last one the arrival.

metadata keeps its type and its replace semantics. Segments smuggled through
metadata.legs as a JSON string used to be stored and then ignored by everything
that read them; they are refused now and point at the legs parameter instead.

Closes #1914

* fix(atlas): refuse a bucket list entry that is already on the list

Nothing gave a bucket entry an identity, so the same country could be added as
often as it was clicked. A wish is now identified by name, country, target date
and, where it has them, coordinates. A different target date is a different wish
and still lands, which is what the report asked for; two real places sharing a
name stay separate because their coordinates differ.

Empty string and NULL are normalised on both sides of the comparison, otherwise
the dateless case, which is the one that was reported, would slip through.

Deliberately no unique index and no migration. better-sqlite3 is synchronous and
the server is a single process, so the race an index would guard against cannot
happen here, and an index would have needed a destructive dedupe of existing
rows first.

create_bucket_list_item had no target_date parameter at all, so over MCP the
allowed "same place, other date" case was unreachable. It takes one now. The
plugin RPC reports the duplicate as bad params instead of letting it surface as
an internal error.

Closes #1898

* fix(settings): keep a free-form LLM endpoint in admin hands

Any account could name the address the server calls, and the guard in front of
it deliberately allows loopback and LAN so a self-hosted Ollama works. That is a
reasonable trade for whoever runs the instance, because the network is theirs. It
is not one for an arbitrary account, which could aim a request from inside that
network at a target only it chose and read how the target answered.

A free base URL, and the local provider that is one by another name, now come
only from an admin controlled source: the addon config, the instance-wide
defaults, or an admin's own settings. OpenAI and Anthropic stay available to
everyone, because they go to a fixed address with the user's own key.

The resolver is where this is enforced, since it is the single point both the
booking import and the plugin host pass through, and the only one that also
covers values already sitting in the database. The write path refuses as well,
so nothing is stored that would never be read.

The hint text promised something users no longer have, so it changed with it.

Closes #1772

* fix(calendar): span a date-only booking to its end date

A DATE event without DTEND is exactly one day per RFC 5545, so a multi-day
booking that carries an end date still collapsed onto its first day. It gets an
exclusive DTEND now, built the same way the stay branch already builds one. A
booking without an end date serialises byte for byte as before.

While in here: the stays query joins reservations on the accommodation, and two
bookings pointed at the same stay fanned it out into two events sharing one UID.
Duplicate UIDs in a feed are undefined behaviour and calendar clients pick one at
random.

The same "at most one booking per stay" assumption sat in the accommodations
service, where it was worse than cosmetic: accommodation_id carries no foreign
key, so deleting a stay took only the first booking and left the second pointing
at something that no longer existed. Both the sync and the delete walk all of
them now. The callers keep reading linkedReservationId; the list is additional.

Refs #1869

* fix(vacay): say that the region is required, because it is

The placeholder called the region optional while the add button stayed disabled
until one was picked. The wording predates multiple calendars per plan, which is
what introduced the gate. Only the value of the existing key changed, in all
locales, so the key set stays identical.

Two things the same screen got wrong were in the way of a clean answer:

The button was clickable while the region list was still loading, which put the
user in exactly the state the wording describes, and it is the state the report
was filed from. It waits for the list now.

Changing the country on an existing calendar kept the previous country's regions
on screen, so a German region could be picked for the Netherlands. The list is
cleared with the country, which only works in combination with the loading gate:
without it, the empty list would happen to enable the button instead.

Closes #1813

* fix(collections): let a saved place's address be corrected

The edit form offered name, category and labels, and the address only on the way
in. The cause was not the form: the update contract never declared address, so
the validation pipe stripped it and the UPDATE had no column for it. Coordinates
were pulled through for #1435 and the address was missed.

It travels the whole chain now, contract to service to both forms. No migration,
the column has always been there.

The mobile add sheet had no address input at all, showing whatever the search
result carried as read-only, so editing would have worked while creating still
would not. It has one.

The MCP tool description had drifted further than the address, never having
mentioned the coordinates either. Both are in it now.

Closes #1870

* fix(collections): keep the place search usable after the first save

The add dialog stays open on purpose so several places can be added in a row, but
after a save it gave no way back into the search. A search with no results was
silent, with no empty state and no toast, so typing another name and pressing
enter looked like nothing happened. The reporter described both halves and it is
the silent search that matches "nothing happens" most closely.

There is an empty state now, using a key that already existed in every locale and
was unused. The search row is sticky inside the dialog body, so it stays reachable
after the save instead of sitting above the scroll position.

Also fixed while in here: refreshing the collection after each add reset the
select mode, the selection and the label filter. In the very flow this dialog is
built for, the user quietly lost all three.

Closes #1921

* fix(vacay): make the phone year grid and the dock button do what they look like

Two separate faults met on this screen.

Tapping a day in the year grid did nothing unless the edit mode was already on,
and the only way into it was a floating button that looks exactly like the one
the dock draws in the same spot. A tap now opens that month in the editor, where
the cells are big enough to hit. At 390px the grid cells are around 21px, which
is too small to be mutating data with in the first place.

The dock's plus was not wired for this route, so it fell through to creating a
trip on the dashboard. Two different actions were stacked in one place, and the
wrong one was on top whenever the screen had not painted yet, which includes the
time its chunk is still loading. The dock leaves the slot to the screen now, and
holds the geometry so the tab groups stay symmetric.

Closes #1811

* fix(budget): settle in whole cents so balances stop drifting

The report is a balance that moves on its own, with nothing added, edited or
removed, on a trip where everything is in euro. Two mechanisms were behind it and
both are real.

The display currency is a fourth value, a per-user setting, so "everything is in
euro" never covered it. Each balance was converted and rounded on its own, and
the set no longer summed to zero: at 0.855 and 0.9312 it came out one cent over,
at 0.94 it came out even. That cent appears and disappears with the six hour rate
cache alone, which is exactly the symptom. The flows were rounded separately from
the balances, so the two never had to agree.

And a receipt line nobody was ticked for counted toward the total, and therefore
toward the payer's credit, while landing in no share. That difference is
permanent, and the client sent it that way.

The settlement converts once, at the edge, and does integer arithmetic after
that. Equal splits distribute the credited cents by largest remainder rather than
the entered total, so credits and debits of one expense cancel exactly even in a
foreign currency. Display conversion goes through the same largest-remainder
allocation, so the sum survives it.

With that, the 0.01 epsilon can go. It could never create a cent, but it stranded
them: a one cent balance was filtered out of the flows while staying visible in
the list, which is the "settle up offers nothing" half of the report.

Closes #1382

* fix(journey): reach the book export and the suggestion toggle on a phone

Below 1024px the hero card is dropped, and it held the only way to either action.
The floating bar that replaces it was never given them. It has them now, and the
hero is unmounted rather than hidden, so there is no second invisible copy of the
same controls in the document. The bar's own buttons moved onto the appearance
tokens while they were being touched.

The phone screen never had either action at all and now carries both.

The book itself was hardwired to English, cover and dates included, while the
keys for it had been sitting in every locale unused. It takes the page's
translator and locale, sets the document language, and prints mood and weather,
which were on screen but never in the book.

Closes #1848

* fix(journey): offer the connected photo providers in the phone entry editor

The desktop editor grew an external photo source in #1632; the phone editor that
came out of the mobile rewrite never did, so editing an entry on a phone had no
way to Immich or Synology. It mirrors the desktop mechanics, including the part
that is easy to miss: a save that creates the entry and then fails on the photos
keeps the sheet open, and without remembering what was just created the retry
creates a second entry.

The sheet no longer kicks off its own addon load. The list is loaded once on
boot and this only reacts to it; a second load races the other consumers and
overwrites the store when it lands.

Closes #1808

* fix(mobile): let the document scroll so Safari can collapse its URL bar

iOS Safari only shrinks its address bar when the document scrolls, and TREK's
never did: the root was locked and the mobile shell put a scroll container inside
it. On the bottom tab layout that costs about a sixth of the viewport,
permanently.

Below 768px the document is the scroller again and the full-bleed screens measure
their own height instead of leaning on the shell.

That makes the body scroll locks real for the first time on a phone, and they
were not ready for it. Three of them reset the overflow unconditionally, one of
those on every run of its effect rather than on unmount, so a system notice could
hand the page back while a sheet was still open. They all go through one
reference counted lock now, which is the part that had to land first.

Worth knowing for the issue: this reaches the flow screens. The trip planner, the
journal, the atlas and the vacay screen keep their own full-height scrollers and
still will not collapse the bar.

Closes #1809

* fix(mobile): keep the screen gradient visible behind the translucent cards

The gradient layer introduced with the scroll change sat at z-index -10. A
negative layer paints above the canvas but below the backgrounds of ordinary
blocks, and body carries an opaque one, so the gradient was hidden behind it.
The mobile cards are translucent and take their depth from what is behind them,
so every surface ended up compositing against a single flat colour and the whole
phone UI read as one tone.

The layer is positioned at z-index 0 now and the content above it at 10, which
puts both over body's background. Same visual as before the scroll change, and
the shell still owns no scroll container.

* test(reservations): teach the accommodation delete stub about the id lists

The RPC walks every linked booking since the delete stopped stopping at the
first one. The stub still answered with the two singular fields, so the loop had
nothing to iterate and the cascade events never fired.

* fix(settings): move the free-form LLM endpoint out of user settings entirely

Hiding it from non-admins closed the hole, but it left the option sitting in the
admin's own personal settings, which is not what was asked for and not where it
belongs: an instance has exactly one address it calls out to, and there was a
second, invisible place to set it.

The provider list is now the two hosted ones for everybody, the endpoint field is
gone, and the resolver takes the address from the admin-controlled instance
defaults for every caller including an admin. It no longer reads the users table
at all. The write path refuses the keys for everyone, so nothing can be stored
that would never be read.

The endpoint is configured on the addon in the admin settings, which already
carries provider, model, base URL and key, plus the Ollama model list. Nothing
is lost, it just has one home now.

Behaviour change worth a release note: an admin who set a personal Ollama
endpoint has to move it to the addon config. A non-admin was already in that
position after the previous commit.

Refs #1772

* fix(journey): make the book preview usable on a phone

The preview document carried no viewport, so a phone laid it out at its default
980px while the page is A4 landscape and about 1123px wide. It spilled out of
the frame, which reads as being zoomed far in. Declaring the real width lets the
browser scale the page to fit. Printing is unaffected, it goes by @page.

The preview chrome had the same problem in miniature: a title and two labels on
one row, sized inline, which does not fit a phone and fits translated labels
even less. It moved to a stylesheet, and below 640px the card goes full-bleed,
the decorative title steps aside and the two actions share the width.

Refs #1848

* fix(mobile): stop the shell trapping the screens' own overlays

Lifting the background layer into the positioned range meant the content wrapper
had to be positioned too, and a positioned wrapper with a z-index is a stacking
context. Every fixed overlay a screen renders inside it got clamped to that
level: the vacay view/edit FAB sits at z-50 precisely so it beats the dock at
z-40, and it disappeared behind it.

The layer goes back to a negative z-index and the wrapper back to no positioning
at all. What it needed in the first place was for body to stop painting over it,
which is one line in the phone media block rather than a new stacking context.

* fix(mobile): let the phone background override actually win

A media query adds no specificity, so the themed body rule further down the file
kept winning on source order and painted over the shell's background layer. The
override is on html body now.

The gradient was back to being invisible, which is the flat single-tone phone UI
again.

* fix(vacay): draw today's ring around the day instead of replacing it

On the phone, today short-circuited the day-cell matrix and returned a ring on an
empty cell. A vacation day or a company holiday logged for today was stored and
counted in the totals, but the cell showed nothing, so it read as if the tap had
not taken.

Today is an annotation now, layered like the school band and the shared-calendar
rings, which is how the desktop month card has always treated it. The strong ink
stays only where the cell has no fill of its own, since --m-ink is light in dark
mode and would disappear on a pastel.

* fix(map): stop serving a stale GL style document from the service worker

The Workbox rule for Mapbox matched every URL under api.mapbox.com, and the
style document lives there too. Under StaleWhileRevalidate the map was built
from the previous revision on every load, with the refresh landing one load too
late, so a style republished in Studio with different label languages kept
rendering the old ones. The network tab showed a 200 throughout, because the
service worker's own answer is a real 200, which is why this looked like the app
rewriting text-field at runtime.

The style now has its own rule ahead of the tile rules, on NetworkFirst so it is
current whenever the network is, and still cached so the map opens offline. Its
own small cache too: sharing an LRU with three thousand tiles made the staleness
come and go at random. OpenFreeMap styles had the same problem and are covered
by the same rule.

Closes #1924
This commit is contained in:
Maurice
2026-08-13 14:06:35 +02:00
committed by GitHub
parent 3c5e406229
commit 9dd0a851ca
176 changed files with 4988 additions and 690 deletions
+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)
})
@@ -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]
@@ -1762,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 })
})
})
+8 -2
View File
@@ -1234,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)
@@ -1248,7 +1254,7 @@ 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,
@@ -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>
@@ -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)
@@ -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" />
+11 -1
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()
@@ -87,6 +88,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 +160,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 && (
@@ -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
@@ -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
+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)
+11 -1
View File
@@ -7,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'
@@ -393,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)
@@ -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 })),
+53 -27
View File
@@ -78,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 />
@@ -109,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"
@@ -118,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} />
@@ -140,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} />
@@ -150,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>
)}
@@ -184,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 && (
<>
@@ -223,24 +251,21 @@ function JourneyDetailPageDesktop() {
</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>
@@ -267,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
@@ -155,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 () => {
@@ -408,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]);
@@ -422,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', () => {
@@ -493,4 +498,37 @@ describe('JourneyDetailPage wiring', () => {
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();
});
});
+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> => {
@@ -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'
@@ -273,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
+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')
})
})
@@ -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();
});
});
+22 -1
View File
@@ -1,4 +1,4 @@
// FE-MOB-MVACSCR-001 to FE-MOB-MVACSCR-016
// FE-MOB-MVACSCR-001 to FE-MOB-MVACSCR-021
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, within } from '../../../helpers/render';
@@ -99,6 +99,7 @@ function buildV(over: Record<string, unknown> = {}): Record<string, unknown> {
selectedUserId: 1,
selectPerson: vi.fn((_id: number) => undefined),
handleDayTap: vi.fn(async (_date: string) => {}),
openMonthSlot: vi.fn((_slot: number) => undefined),
allowInc: vi.fn(() => undefined),
allowDec: vi.fn(() => undefined),
prevYear: vi.fn(async () => {}),
@@ -274,6 +275,15 @@ describe('MVacay', () => {
expect(mocks.v.handleDayTap).toHaveBeenCalledWith('2026-06-15');
});
it('FE-MOB-MVACSCR-013b: the month chip in the year grid opens that month (#1811)', () => {
render(<MVacay />);
const chip = screen.getByRole('button', { name: 'Mar' });
fireEvent.click(chip);
expect(mocks.v.openMonthSlot).toHaveBeenCalledWith(2);
});
it('FE-MOB-MVACSCR-014: edit view swaps in the month nav, weekday row and mode switch', () => {
mocks.v = buildV({ view: 'edit' });
render(<MVacay />);
@@ -367,4 +377,15 @@ describe('MVacay', () => {
expect(screen.queryByRole('dialog', { name: 'Fusion Request' })).not.toBeInTheDocument();
});
it('FE-MOB-MVACSCR-021: the screen measures itself, not the shell', () => {
// #1809: the shell scrolls with the document now and hands down no definite
// height, so a full-viewport screen has to set its own.
const { container, rerender } = render(<MVacay />);
expect(container.firstElementChild).toHaveClass('h-dvh');
mocks.v = buildV({ loading: true });
rerender(<MVacay />);
expect(container.firstElementChild).toHaveClass('h-dvh');
});
});
@@ -1,4 +1,4 @@
// FE-MOB-MVACSET-001 to FE-MOB-MVACSET-024
// FE-MOB-MVACSET-001 to FE-MOB-MVACSET-030
import React from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
@@ -335,7 +335,7 @@ describe('MVacaySettingsSheet', () => {
fireEvent.change(screen.getByRole('combobox', { name: 'Select country' }), { target: { value: 'NL' } });
expect(updateHolidayCalendar).toHaveBeenCalledWith(3, { region: 'NL' });
const region = await screen.findByRole('combobox', { name: 'Select region (optional)' });
const region = await screen.findByRole('combobox', { name: 'Select region (required)' });
fireEvent.change(region, { target: { value: 'DE-BY' } });
expect(updateHolidayCalendar).toHaveBeenLastCalledWith(3, { region: 'DE-BY' });
});
@@ -370,6 +370,9 @@ describe('MVacaySettingsSheet', () => {
await waitForCountries();
fireEvent.change(screen.getByPlaceholderText('Label (optional)'), { target: { value: ' Feiertage ' } });
fireEvent.change(screen.getByRole('combobox', { name: 'Select country' }), { target: { value: 'DE' } });
// Germany has no regional holidays here, but the draft only unlocks once that
// answer is in (see FE-MOB-MVACSET-029).
await waitFor(() => expect(screen.getByRole('button', { name: 'Add' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith({
@@ -388,7 +391,7 @@ describe('MVacaySettingsSheet', () => {
await waitForCountries();
fireEvent.change(screen.getByRole('combobox', { name: 'Select country' }), { target: { value: 'DE' } });
const region = await screen.findByRole('combobox', { name: 'Select region (optional)' });
const region = await screen.findByRole('combobox', { name: 'Select region (required)' });
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
fireEvent.change(region, { target: { value: 'DE-NW' } });
@@ -413,7 +416,7 @@ describe('MVacaySettingsSheet', () => {
expect(within(country).queryByRole('option', { name: 'United States' })).not.toBeInTheDocument();
fireEvent.change(country, { target: { value: 'NL' } });
const region = await screen.findByRole('combobox', { name: 'Select region (optional)' });
const region = await screen.findByRole('combobox', { name: 'Select region (required)' });
fireEvent.change(region, { target: { value: 'NL|group:NL-NO' } });
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
@@ -446,7 +449,7 @@ describe('MVacaySettingsSheet', () => {
renderSheet();
expect(screen.getAllByPlaceholderText('Label (optional)')).toHaveLength(2);
const region = await screen.findByRole('combobox', { name: 'Select region (optional)' });
const region = await screen.findByRole('combobox', { name: 'Select region (required)' });
fireEvent.change(region, { target: { value: 'DE-NW' } });
expect(updateHolidayCalendar).toHaveBeenCalledWith(5, { region: 'DE-NW' });
@@ -471,7 +474,7 @@ describe('MVacaySettingsSheet', () => {
// The `|group:` suffix must not leak into the country, or the lookup comes back empty.
const country = screen.getByRole('combobox', { name: 'Select country' });
expect(country).toHaveValue('NL');
const region = await screen.findByRole('combobox', { name: 'Select region (optional)' });
const region = await screen.findByRole('combobox', { name: 'Select region (required)' });
expect(region).toHaveValue('NL|group:NL-NO');
fireEvent.change(region, { target: { value: 'NL|group:NL-ZU' } });
@@ -535,4 +538,73 @@ describe('MVacaySettingsSheet', () => {
// Only the disabled placeholder option survives — the label falls back to it too.
expect(screen.getAllByText('Select country')).toHaveLength(2);
});
it('FE-MOB-MVACSET-029: 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'] },
]);
}));
seedPlan(buildPlan({ holidays_enabled: true }), { addHolidayCalendar: vi.fn(async () => {}) });
renderSheet();
fireEvent.click(screen.getByRole('button', { name: 'Add calendar' }));
await waitForCountries();
fireEvent.change(screen.getByRole('combobox', { name: 'Select country' }), { target: { value: 'DE' } });
// 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?.();
const region = await screen.findByRole('combobox', { name: 'Select region (required)' });
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Add' })).toHaveAttribute('aria-busy', 'false');
fireEvent.change(region, { target: { value: 'DE-NW' } });
expect(screen.getByRole('button', { name: 'Add' })).toBeEnabled();
});
it('FE-MOB-MVACSET-030: 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)) },
});
});
seedPlan(
buildPlan({
holidays_enabled: true,
holiday_calendars: [{ id: 3, plan_id: 1, region: 'DE', label: null, color: '#fecaca', sort_order: 0 }],
}),
{ updateHolidayCalendar },
);
renderSheet();
await waitForCountries();
await screen.findByRole('combobox', { name: 'Select region (required)' });
fireEvent.change(screen.getByRole('combobox', { name: 'Select country' }), { target: { value: 'NL' } });
expect(updateHolidayCalendar).toHaveBeenCalledWith(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('combobox', { name: 'Select region (required)' })).not.toBeInTheDocument());
await waitFor(() => expect(releaseNl).toBeDefined());
releaseNl?.();
await screen.findByRole('combobox', { name: 'Select region (required)' });
});
});
@@ -1,4 +1,4 @@
// FE-MOB-MVAC-001 to FE-MOB-MVAC-025
// FE-MOB-MVAC-001 to FE-MOB-MVAC-029
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
@@ -272,14 +272,19 @@ describe('useMVacay', () => {
expect(result.current.mode).toBe('vacation');
});
it('FE-MOB-MVAC-015: the year overview never logs a day', async () => {
it('FE-MOB-MVAC-015: the year overview never logs a day, it opens that month instead (#1811)', async () => {
const toggleEntry = vi.fn(async (_date: string, _userId?: number, _fraction?: 0.5 | 1, _kind?: 'vacation' | 'comp') => {});
act(() => { useVacayStore.setState({ toggleEntry }); });
const { result } = await mount();
act(() => { result.current.setMonthSlot(0); });
expect(result.current.view).toBe('grid');
await act(async () => { await result.current.handleDayTap('2026-06-15'); });
expect(toggleEntry).not.toHaveBeenCalled();
expect(result.current.view).toBe('edit');
expect(result.current.monthSlot).toBe(5);
expect(result.current.activeMonth).toEqual({ year: 2026, month: 5 });
});
it('FE-MOB-MVAC-016: logs the selected person with the half-day and comp modifiers', async () => {
@@ -442,4 +447,42 @@ describe('useMVacay', () => {
await act(async () => { await result.current.handleDayTap('2026-06-14'); });
expect(toggleEntry).toHaveBeenCalledTimes(1);
});
it('FE-MOB-MVAC-027: zooming out of the overview resolves the slot in the shifted window (#737)', async () => {
act(() => {
useVacayStore.setState({
yearSettings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1, hire_date: null },
});
});
const { result } = await mount();
act(() => { result.current.setMonthSlot(0); });
// Slot 6 of a July window is January of the following calendar year.
await act(async () => { await result.current.handleDayTap('2027-01-10'); });
expect(result.current.monthSlot).toBe(6);
expect(result.current.activeMonth).toEqual({ year: 2027, month: 0 });
});
it('FE-MOB-MVAC-028: a day outside the rendered window leaves the overview alone', async () => {
act(() => {
useVacayStore.setState({
yearSettings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1, hire_date: null },
});
});
const { result } = await mount();
act(() => { result.current.setMonthSlot(3); });
// Jan 2026 has no card in the Jul 2026 - Jun 2027 window.
await act(async () => { await result.current.handleDayTap('2026-01-10'); });
expect(result.current.view).toBe('grid');
expect(result.current.monthSlot).toBe(3);
});
it('FE-MOB-MVAC-029: the month chip opens its slot in the editor', async () => {
const { result } = await mount();
act(() => { result.current.openMonthSlot(9); });
expect(result.current.view).toBe('edit');
expect(result.current.monthSlot).toBe(9);
});
});
@@ -16,6 +16,50 @@ function ctx(overrides: Partial<DayVisualContext> = {}): DayVisualContext {
}
describe('vacayDayModel', () => {
describe('today is a ring, not a state', () => {
const entry = { date: '2026-07-15', user_id: 1, person_color: '#EC4899' };
it('FE-MOB-VACAY-020: a vacation day logged for today keeps its person fill', () => {
const visual = dayVisual('2026-07-15', 3, ctx({ entryMap: { '2026-07-15': [entry] } }));
expect(visual.background).toBe(personTint('#EC4899'));
expect(visual.segments).toHaveLength(1);
expect(visual.numColor).toBe('#101013');
expect(visual.boxShadow).toBe('inset 0 0 0 1.5px var(--m-ink)');
});
it('FE-MOB-VACAY-021: a company holiday on today keeps its fill and its own ink', () => {
const visual = dayVisual('2026-07-15', 3, ctx({ companyHolidaySet: new Set(['2026-07-15']) }));
expect(visual.background).toBe('#F5D9A6');
// Not --m-ink: that is light in dark mode and would vanish on the pastel.
expect(visual.numColor).toBe('#8A5A00');
expect(visual.boxShadow).toBe('inset 0 0 0 1.5px var(--m-ink)');
});
it('FE-MOB-VACAY-022: a plain today still gets the emphasis it always had', () => {
expect(dayVisual('2026-07-15', 3, ctx())).toEqual({
background: 'transparent',
numColor: 'var(--m-ink)',
boxShadow: 'inset 0 0 0 1.5px var(--m-ink)',
});
});
it('FE-MOB-VACAY-023: a weekend today reads as a weekend and keeps the ring', () => {
const visual = dayVisual('2026-07-15', 0, ctx());
expect(visual.background).toBe('var(--m-ic)');
expect(visual.numColor).toBe('var(--m-ink)');
expect(visual.boxShadow).toBe('inset 0 0 0 1.5px var(--m-ink)');
});
it('FE-MOB-VACAY-024: shared-calendar rings still nest inside the today ring', () => {
const visual = dayVisual('2026-07-15', 3, ctx({ sharedMap: { '2026-07-15': [{ color: '#22c55e' }] } }));
expect(visual.boxShadow).toBe('inset 0 0 0 1.5px var(--m-ink), inset 0 0 0 3px #22c55e');
});
});
it('FE-MOB-VACAY-001: applies the day-cell matrix in priority order', () => {
const entry = { date: '2026-07-15', user_id: 1, person_color: '#EC4899' };
const holiday = { name: 'Holiday', localName: 'Feiertag', color: '#fecaca', label: null };
@@ -25,10 +69,12 @@ describe('vacayDayModel', () => {
holidays: { '2026-07-15': holiday },
});
// Today wins over everything: ring, no fill.
// Today does not win, it annotates: the day keeps the fill it would have had
// and gets the ring on top. Deciding it as its own visual meant anything
// logged for today was counted but drawn as an empty cell.
expect(dayVisual('2026-07-15', 3, full)).toEqual({
background: 'transparent',
numColor: 'var(--m-ink)',
background: '#F5D9A6',
numColor: '#8A5A00',
boxShadow: 'inset 0 0 0 1.5px var(--m-ink)',
});
@@ -477,3 +477,45 @@ describe('the map-tiles rule for OpenStreetMap (#1733)', () => {
expect(pattern.test('https://tile.openstreetmap.de/13/4091/2722.png')).toBe(false);
});
});
describe('the GL style document is not served stale (#1924)', () => {
const config = readFileSync(resolve(process.cwd(), 'vite.config.js'), 'utf8');
// Read the rules in file order and exercise them the way Workbox does: the
// first route whose pattern matches wins, so order is behaviour here, not style.
const rules = [...config.matchAll(/urlPattern:\s*(\/[^\n]*?\/i),\s*\n\s*handler:\s*'([A-Za-z]+)'/g)].map(m => ({
pattern: new RegExp(m[1].slice(1, m[1].lastIndexOf('/')), 'i'),
handler: m[2],
}));
const firstMatch = (url: string) => rules.find(r => r.pattern.test(url));
const MAPBOX_STYLE = 'https://api.mapbox.com/styles/v1/acme/ckabc123?sdk=js-3.25.0&access_token=pk.test';
const OPENFREEMAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
const MAPBOX_TILE = 'https://a.tiles.mapbox.com/v4/mapbox.mapbox-streets-v8/13/4091/2722.vector.pbf';
const MAPBOX_GLYPHS = 'https://api.mapbox.com/fonts/v1/mapbox/DIN%20Pro%20Regular/0-255.pbf';
it('finds the runtime caching rules at all (guards the parser above)', () => {
expect(rules.length).toBeGreaterThan(4);
});
it('takes the Mapbox style from the network first', () => {
// Under the tile rule's StaleWhileRevalidate the map was built from the
// previous revision on every load, so a style republished with different
// label languages kept rendering the old ones.
expect(firstMatch(MAPBOX_STYLE)?.handler).toBe('NetworkFirst');
});
it('takes the OpenFreeMap style from the network first too', () => {
expect(firstMatch(OPENFREEMAP_STYLE)?.handler).toBe('NetworkFirst');
});
it('leaves tiles, glyphs and sprites on the offline-friendly rule', () => {
// Those are safe to serve stale and are what makes the basemap work offline.
expect(firstMatch(MAPBOX_TILE)?.handler).toBe('StaleWhileRevalidate');
expect(firstMatch(MAPBOX_GLYPHS)?.handler).toBe('StaleWhileRevalidate');
});
it('keeps the style out of the tile cache, so tile eviction cannot reach it', () => {
expect(config).toMatch(/cacheName:\s*'gl-map-styles'/);
});
});
+32 -3
View File
@@ -77,8 +77,36 @@ export default defineConfig(({ mode }) => ({
},
},
{
// Mapbox GL style, glyphs, sprites and vector tiles. Best-effort
// offline only: opportunistically caches what the user has already
// The GL style DOCUMENT, for both providers. It has to be matched
// before the tile rules below, because Workbox takes the first route
// that matches and the broad tile patterns cover this URL too (#1924).
//
// A style is not a tile. It is one small document that changes the
// moment the user edits it in Mapbox Studio, and it decides how every
// tile is drawn — including which `name_*` field the labels read.
// Under StaleWhileRevalidate the map was built from the PREVIOUS
// revision on every load, with the refresh landing one load too late,
// so a style republished with different label languages kept rendering
// the old ones. The network tab showed a 200 throughout, because the
// service worker's own answer is a real 200.
//
// NetworkFirst rather than NetworkOnly: the map still has to open
// offline. Its own small cache rather than the tile cache: sharing
// an LRU with thousands of tiles made eviction, and therefore the
// staleness, look random.
urlPattern: /^https:\/\/(api\.mapbox\.com\/styles\/v1\/|tiles\.openfreemap\.org\/styles\/)/i,
handler: 'NetworkFirst',
options: {
cacheName: 'gl-map-styles',
networkTimeoutSeconds: 5,
expiration: { maxEntries: 20, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [200] },
},
},
{
// Mapbox GL glyphs, sprites and vector tiles (the style itself is
// handled by the rule above). Best-effort offline only:
// opportunistically caches what the user has already
// viewed online. Full pre-download offline maps require the Leaflet
// renderer (raster prefetch in tilePrefetcher.ts) — the GL vector
// pipeline is not prefetched. StaleWhileRevalidate keeps the basemap
@@ -93,7 +121,8 @@ export default defineConfig(({ mode }) => ({
},
},
{
// OpenFreeMap MapLibre style, glyphs, sprites and vector tiles.
// OpenFreeMap MapLibre glyphs, sprites and vector tiles (the style
// itself is handled by the style rule above).
// Same best-effort offline model as Mapbox GL: viewed resources are
// reused from cache, but the vector tile pipeline is not prefetched.
urlPattern: /^https:\/\/tiles\.openfreemap\.org\/.*/i,
@@ -111,12 +111,12 @@ export class AccommodationsController {
if (!this.accommodations.get(id, tripId)) {
throw new HttpException({ error: 'Accommodation not found' }, 404);
}
const { linkedReservationId, deletedBudgetItemId } = this.accommodations.remove(id);
if (linkedReservationId) {
this.accommodations.broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId }, socketId);
const { linkedReservationIds, deletedBudgetItemIds } = this.accommodations.remove(id);
for (const reservationId of linkedReservationIds) {
this.accommodations.broadcast(tripId, 'reservation:deleted', { reservationId }, socketId);
}
if (deletedBudgetItemId) {
this.accommodations.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, socketId);
for (const itemId of deletedBudgetItemIds) {
this.accommodations.broadcast(tripId, 'budget:deleted', { itemId }, socketId);
}
this.accommodations.broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, socketId);
return { success: true };
@@ -179,9 +179,11 @@ export class AccommodationsMcp {
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.accommodations.getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
const { linkedReservationId } = this.accommodations.deleteAccommodation(accommodationId);
this.guards.safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId });
return ok({ success: true, linkedReservationId });
// linkedReservationId stays the first one so the tool's answer keeps its shape;
// linkedReservationIds carries the rest for a block that had more than one booking.
const { linkedReservationId, linkedReservationIds } = this.accommodations.deleteAccommodation(accommodationId);
this.guards.safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId, linkedReservationIds });
return ok({ success: true, linkedReservationId, linkedReservationIds });
}
@ResourceTemplate({
@@ -99,9 +99,9 @@ export class AccommodationsRpc {
throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`);
}
// Deleting a block can take its partner reservation and budget item with it.
const { linkedReservationId, deletedBudgetItemId } = this.days.deleteAccommodation(accommodationId);
if (linkedReservationId) this.realtime.broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId });
if (deletedBudgetItemId) this.realtime.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId });
const { linkedReservationIds, deletedBudgetItemIds } = this.days.deleteAccommodation(accommodationId);
for (const reservationId of linkedReservationIds) this.realtime.broadcast(tripId, 'reservation:deleted', { reservationId });
for (const itemId of deletedBudgetItemIds) this.realtime.broadcast(tripId, 'budget:deleted', { itemId });
this.realtime.broadcast(tripId, 'accommodation:deleted', { accommodationId });
return { deleted: true };
}
@@ -198,36 +198,59 @@ export class AccommodationsService {
newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id
);
// Sync check-in/out/confirmation to linked reservation
const linkedRes = this.db.get<{ id: number; metadata: string | null }>('SELECT id, metadata FROM reservations WHERE accommodation_id = ?', Number(id));
if (linkedRes) {
const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {};
// Sync check-in/out/confirmation to every linked reservation. The booking form
// lets more than one hotel booking point at the same block and there is no
// unique constraint on reservations.accommodation_id, so a single .get() would
// silently leave the others on the old times.
const linkedRes = this.db.all<{ id: number; metadata: string | null }>('SELECT id, metadata FROM reservations WHERE accommodation_id = ?', Number(id));
for (const res of linkedRes) {
const meta = res.metadata ? JSON.parse(res.metadata) : {};
if (newCheckIn) meta.check_in_time = newCheckIn;
if (newCheckInEnd) meta.check_in_end_time = newCheckInEnd;
if (newCheckOut) meta.check_out_time = newCheckOut;
this.db.run('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?',
JSON.stringify(meta), newConfirmation || null, linkedRes.id);
JSON.stringify(meta), newConfirmation || null, res.id);
}
return this.getAccommodationWithPlace(Number(id));
}
/** Delete accommodation and its linked reservation (and any linked budget item), atomically. */
deleteAccommodation(id: string | number): { linkedReservationId: number | null; deletedBudgetItemId: number | null } {
/**
* Delete accommodation and its linked reservations (and any linked budget items),
* atomically.
*
* Takes ALL linked reservations, not just the first: reservations.accommodation_id
* carries no foreign key and no unique constraint, so a second booking pointed at
* the same block used to survive the delete as a row referencing an accommodation
* that no longer exists. `linkedReservationId` / `deletedBudgetItemId` stay on the
* result as the first of each, because the RPC, MCP and REST callers read them.
*/
deleteAccommodation(id: string | number): {
linkedReservationId: number | null;
deletedBudgetItemId: number | null;
linkedReservationIds: number[];
deletedBudgetItemIds: number[];
} {
return this.db.transaction(() => {
const linkedRes = this.db.get<{ id: number }>('SELECT id FROM reservations WHERE accommodation_id = ?', Number(id));
let deletedBudgetItemId: number | null = null;
if (linkedRes) {
const linkedBudget = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE reservation_id = ?', linkedRes.id);
const linkedRes = this.db.all<{ id: number }>('SELECT id FROM reservations WHERE accommodation_id = ?', Number(id));
const deletedBudgetItemIds: number[] = [];
for (const res of linkedRes) {
const linkedBudget = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE reservation_id = ?', res.id);
if (linkedBudget) {
this.db.run('DELETE FROM budget_items WHERE id = ?', linkedBudget.id);
deletedBudgetItemId = linkedBudget.id;
deletedBudgetItemIds.push(linkedBudget.id);
}
this.db.run('DELETE FROM reservations WHERE id = ?', linkedRes.id);
this.db.run('DELETE FROM reservations WHERE id = ?', res.id);
}
this.db.run('DELETE FROM day_accommodations WHERE id = ?', id);
return { linkedReservationId: linkedRes ? linkedRes.id : null, deletedBudgetItemId };
const linkedReservationIds = linkedRes.map(r => r.id);
return {
linkedReservationId: linkedReservationIds[0] ?? null,
deletedBudgetItemId: deletedBudgetItemIds[0] ?? null,
linkedReservationIds,
deletedBudgetItemIds,
};
});
}
}
+25 -4
View File
@@ -16,7 +16,7 @@ import {
import type { Response } from 'express';
import type { AtlasLocateResponse, RegionGeo } from '@trek/shared';
import type { User } from '../../types';
import { AtlasService } from './atlas.service';
import { AtlasService, BucketItemExistsError } from './atlas.service';
import { AtlasMarkRegionDto, AtlasCreateBucketItemDto, AtlasUpdateBucketItemDto } from './atlas.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -33,7 +33,9 @@ import { CurrentUser } from '../auth/current-user.decorator';
* hand-rolled 'name and country_code are required' — the todo/places trade);
* the whitespace-only bucket name keeps its legacy 'Name is required' 400.
* No addon gate — the legacy route has none, so adding one would break
* clients when the addon is off.
* clients when the addon is off. The one status the legacy route never sent is
* the bucket-list 409: adding the same wish twice used to append a second row
* (#1898).
*/
@Controller('api/addons/atlas')
@UseGuards(JwtAuthGuard)
@@ -151,7 +153,16 @@ export class AtlasController {
throw new HttpException({ error: 'Name is required' }, 400);
}
const { name, lat, lng, country_code, notes, target_date } = body;
return { item: this.atlas.createBucketItem(user.id, { name, lat, lng, country_code, notes, target_date }) };
try {
return { item: this.atlas.createBucketItem(user.id, { name, lat, lng, country_code, notes, target_date }) };
} catch (err) {
// #1898: the same wish twice is a conflict, not a server error. Bespoke
// { error } body like the neighbouring 400/404s.
if (err instanceof BucketItemExistsError) {
throw new HttpException({ error: 'Already on your bucket list' }, 409);
}
throw err;
}
}
@Put('bucket-list/:id')
@@ -161,7 +172,17 @@ export class AtlasController {
@Body() body: AtlasUpdateBucketItemDto,
): { item: unknown } {
const { name, notes, lat, lng, country_code, target_date } = body;
const item = this.atlas.updateBucketItem(user.id, id, { name, notes, lat, lng, country_code, target_date });
let item: unknown;
try {
item = this.atlas.updateBucketItem(user.id, id, { name, notes, lat, lng, country_code, target_date });
} catch (err) {
// #1898: editing a wish onto an existing one conflicts the same way a
// duplicate create does.
if (err instanceof BucketItemExistsError) {
throw new HttpException({ error: 'Already on your bucket list' }, 409);
}
throw err;
}
if (!item) {
throw new HttpException({ error: 'Item not found' }, 404);
}
+28 -6
View File
@@ -6,7 +6,7 @@ import {
} from '@trek/nest-mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { AtlasService } from './atlas.service';
import { AtlasService, BucketItemExistsError } from './atlas.service';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { AuthService } from '../auth/auth.service';
@@ -16,6 +16,11 @@ import { AuthService } from '../auth/auth.service';
* (see atlas.controller.ts). */
const atlasAddonOn = addonGate(ADDON_IDS.ATLAS);
/** The MCP echo of the REST 409 (#1898) — same wording, tool-shaped. */
function bucketDuplicateResult() {
return { content: [{ type: 'text' as const, text: 'That is already on your bucket list.' }], isError: true };
}
function jsonContent(uri: string, data: unknown) {
return {
contents: [{
@@ -37,7 +42,8 @@ function jsonContent(uri: string, data: unknown) {
* checks and no broadcasts; writes deny demo users, reads do not. The legacy
* registrar's casing divergence from REST (mark_region_visited /
* get_country_atlas_places passed codes through un-uppercased) was fixed in
* the trailing quirk commit — both sides now uppercase.
* the trailing quirk commit — both sides now uppercase. The one input schema
* that grew since is create_bucket_list_item's target_date (#1898).
*/
@McpController()
export class AtlasMcp {
@@ -58,18 +64,28 @@ export class AtlasMcp {
lng: z.number().optional(),
country_code: z.string().length(2).toUpperCase().optional().describe('ISO 3166-1 alpha-2 country code'),
notes: z.string().max(1000).optional(),
// #1898 made the target date part of an item's identity, so without this
// parameter MCP could only ever create one entry per destination — the
// "same place, different date" case the report calls for was unreachable.
// update_bucket_list_item has had the field all along.
target_date: z.string().nullable().optional().describe('When you plan to go, e.g. "2027-05"'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: atlasAddonOn,
access: { group: 'atlas', mode: 'write' },
})
async createBucketListItem(
{ name, lat, lng, country_code, notes }: { name: string; lat?: number; lng?: number; country_code?: string; notes?: string },
{ name, lat, lng, country_code, notes, target_date }: { name: string; lat?: number; lng?: number; country_code?: string; notes?: string; target_date?: string | null },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
const item = this.atlas.createBucketItem(ctx.userId, { name, lat, lng, country_code, notes });
return ok({ item });
try {
const item = this.atlas.createBucketItem(ctx.userId, { name, lat, lng, country_code, notes, target_date });
return ok({ item });
} catch (err) {
if (err instanceof BucketItemExistsError) return bucketDuplicateResult();
throw err;
}
}
@Tool({
@@ -244,7 +260,13 @@ export class AtlasMcp {
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
const item = this.atlas.updateBucketItem(ctx.userId, itemId, { name, notes, lat, lng, country_code, target_date });
let item: unknown;
try {
item = this.atlas.updateBucketItem(ctx.userId, itemId, { name, notes, lat, lng, country_code, target_date });
} catch (err) {
if (err instanceof BucketItemExistsError) return bucketDuplicateResult();
throw err;
}
if (!item) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
return ok({ item });
}
+9 -2
View File
@@ -4,7 +4,7 @@ import { BadParams, ForbiddenResource } from '../plugins/host/rpc-errors';
import { asPayload, num } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { ADDON_IDS } from '../../addons';
import { AtlasService } from './atlas.service';
import { AtlasService, BucketItemExistsError } from './atlas.service';
/**
* The atlas surface a plugin may reach (#plugins): visited countries and regions plus
@@ -86,7 +86,14 @@ export class AtlasRpc {
const input = asPayload(params.input);
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bucket item name is required');
this.requireAtlasAddon();
return this.atlas.createBucketItem(userId, input as never);
try {
return this.atlas.createBucketItem(userId, input as never);
} catch (err) {
// #1898: a repeated wish is the caller's mistake, not ours. Without this the
// plugin sees a generic internal error and cannot tell the two apart.
if (err instanceof BucketItemExistsError) throw new BadParams('bucket item already exists');
throw err;
}
}
@PluginMethod('atlas.deleteBucketItem', { permission: 'db:write:atlas' })
+88 -8
View File
@@ -34,6 +34,15 @@ export type UpdateBucketData = {
target_date?: string | null;
};
/** The columns that decide whether two bucket-list rows are the same wish (#1898). */
type BucketIdentity = {
name: string;
lat: number | null;
lng: number | null;
country_code: string | null;
target_date: string | null;
};
// Bundled/geocoded region codes are always "<countryCode>-<rest>" (ISO 3166-2 format, and
// buildRegionInfo's synthesized fallback follows the same convention) — used to recover a
// region's country when there's no visited_regions row to read it from (a place-derived
@@ -42,6 +51,26 @@ function countryCodeFromRegionCode(regionCode: string): string {
return (regionCode.split('-')[0] || '').toUpperCase();
}
/**
* The same bucket-list wish, added twice (#1898). Deliberately a plain Error and
* not an HttpException: the service is shared with the MCP tools and the plugin
* RPC host, which shape their own errors — only atlas.controller.ts turns this
* into the 409.
*/
export class BucketItemExistsError extends Error {
constructor() {
super('Already on your bucket list');
this.name = 'BucketItemExistsError';
}
}
// An empty string and NULL are the same "not set" for a bucket item's country and
// target date, so they must not produce two different identities (#1898) — the
// bucket forms send '' where the map dialogs send null.
function blankToNull(value: string | null | undefined): string | null {
return value === undefined || value === null || value === '' ? null : value;
}
/**
* DI-native atlas domain service — the legacy services/atlasService SQL
* (stats aggregation, visited countries/regions with the #1490 tombstones,
@@ -742,26 +771,77 @@ export class AtlasService {
return this.db.prepare('SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC').all(userId);
}
/**
* What makes two bucket-list rows the same wish (#1898): the same user, the
* same name (trimmed, case-insensitive) and the same country, target date and
* coordinates. The coordinates are part of it so two genuinely different
* places that share a name ("Altstadt") can sit side by side, and the target
* date is part of it because one entry per planned date is exactly what the
* report asks for. `IS` compares NULLs as equal, so "no coordinates" matches
* "no coordinates" instead of the NULL-is-never-equal SQLite default.
* `lower()` is ASCII-only in SQLite, which is what the client mirrors.
*/
private findDuplicateBucketItem(userId: number, key: BucketIdentity, excludeId?: number): { id: number } | undefined {
return this.db
.prepare(
`SELECT id FROM bucket_list
WHERE user_id = ?
AND lower(trim(name)) = lower(trim(?))
AND country_code IS ?
AND target_date IS ?
AND lat IS ?
AND lng IS ?
AND id IS NOT ?
LIMIT 1`,
)
.get(userId, key.name, key.country_code, key.target_date, key.lat, key.lng, excludeId ?? null) as
| { id: number }
| undefined;
}
createBucketItem(userId: number, data: CreateBucketData) {
const identity: BucketIdentity = {
name: data.name.trim(),
lat: data.lat ?? null,
lng: data.lng ?? null,
country_code: blankToNull(data.country_code),
target_date: blankToNull(data.target_date),
};
// #1898: the same wish must not stack up. A different target date (or a
// different place under the same name) is a different wish and still lands.
if (this.findDuplicateBucketItem(userId, identity)) throw new BucketItemExistsError();
const result = this.db
.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)',
)
.run(
userId,
data.name.trim(),
data.lat ?? null,
data.lng ?? null,
data.country_code ?? null,
identity.name,
identity.lat,
identity.lng,
identity.country_code,
data.notes ?? null,
data.target_date ?? null,
identity.target_date,
);
return this.db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
}
updateBucketItem(userId: number, itemId: string | number, data: UpdateBucketData) {
const item = this.db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId);
const item = this.db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId) as
| (BucketIdentity & { id: number })
| undefined;
if (!item) return null;
// #1898: an edit must not land on top of another wish either — same guard as
// create, over the values the row will have once the UPDATE below ran. The
// row itself is excluded, so re-saving it unchanged stays a no-op.
const next: BucketIdentity = {
name: data.name?.trim() || item.name,
lat: data.lat !== undefined ? (data.lat ?? null) : item.lat,
lng: data.lng !== undefined ? (data.lng ?? null) : item.lng,
country_code: data.country_code !== undefined ? blankToNull(data.country_code) : blankToNull(item.country_code),
target_date: data.target_date !== undefined ? blankToNull(data.target_date) : blankToNull(item.target_date),
};
if (this.findDuplicateBucketItem(userId, next, item.id)) throw new BucketItemExistsError();
// Post-fold quirk fixes: the value bindings use `?? null` (the legacy
// `|| null` wrote NULL for lat/lng 0 and empty-string notes), and the
// UPDATE + re-select are user-scoped (defense-in-depth; the ownership
@@ -787,9 +867,9 @@ export class AtlasService {
data.lng !== undefined ? 1 : 0,
data.lng !== undefined ? (data.lng ?? null) : null,
data.country_code !== undefined ? 1 : 0,
data.country_code !== undefined ? (data.country_code ?? null) : null,
data.country_code !== undefined ? next.country_code : null,
data.target_date !== undefined ? 1 : 0,
data.target_date !== undefined ? (data.target_date ?? null) : null,
data.target_date !== undefined ? next.target_date : null,
itemId,
userId,
);
+100 -34
View File
@@ -40,6 +40,30 @@ export function splitLegacyTicketNote(
return { note, ticket };
}
/**
* Re-denominate whole trip-currency cents into whole display-currency cents
* without losing or inventing one (#1382).
*
* Rounding every balance on its own lets the rounded set drift away from the sum
* it came from: a squared-up trip viewed in another currency then shows a cent
* that no payment flow can ever clear, and the drift moves whenever the live rate
* does — money appearing without a single expense being touched. Rounding down
* and handing the leftover to the largest fractions keeps Σ(converted) equal to
* converted(Σ). `factor === 1` (the display currency IS the trip currency, the
* common case) is the identity and never touches a float.
*/
function allocateDisplayCents(cents: number[], factor: number): number[] {
if (factor === 1) return [...cents];
const exact = cents.map(c => c * factor);
const out = exact.map(v => Math.floor(v));
const drift = Math.round(cents.reduce((a, c) => a + c, 0) * factor) - out.reduce((a, v) => a + v, 0);
const byFraction = exact
.map((v, i) => ({ i, frac: v - Math.floor(v) }))
.sort((a, b) => b.frac - a.frac || a.i - b.i);
for (let k = 0; k < drift && k < byFraction.length; k++) out[byFraction[k].i] += 1;
return out;
}
/**
* Budget domain service — owns the budget SQL (moved from the legacy
* services/budgetService.ts: identical statements, the `||` falsy-coercion
@@ -590,13 +614,21 @@ export class BudgetService {
return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) }));
}
private splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
/**
* Largest-remainder split of an expense across its participants. Takes and
* returns **whole cents**, so the shares add back up to the input exactly —
* the settlement ledger is netted in integer cents (#1382).
*
* The remainder cent rotates with the item id rather than always landing on the
* first member, so across several expenses the rounding evens out instead of
* always favouring the same person.
*/
private splitEqualShares(totalCents: number, members: { user_id: number }[], itemId: number): Record<number, number> {
const n = members.length;
if (n === 0) return {};
const totalCents = Math.round(total * 100);
const baseCents = Math.floor(totalCents / n);
const remainder = totalCents % n;
const remainder = totalCents - baseCents * n;
const shares: Record<number, number> = {};
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id);
@@ -605,7 +637,7 @@ export class BudgetService {
for (let i = 0; i < n; i++) {
const member = sortedMembers[i];
const hasExtraCent = ((i - startIndex + n) % n) < remainder;
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100;
shares[member.user_id] = baseCents + (hasExtraCent ? 1 : 0);
}
return shares;
@@ -640,8 +672,12 @@ export class BudgetService {
return amount;
};
// trip-currency → display currency, applied once to the final netted totals.
const toDisplay = (v: number): number =>
base === tripCurrency ? v : (rates && rates[tripCurrency] > 0 ? v / rates[tripCurrency] : v);
// Held as a plain factor so it is exactly linear: the balances are converted as
// one set (allocateDisplayCents) rather than one at a time, which is what keeps
// them adding up to zero in whatever currency the viewer picked (#1382).
const displayFactor = base === tripCurrency
? 1
: (rates && rates[tripCurrency] > 0 ? 1 / rates[tripCurrency] : 1);
// A recorded settle-up amount is entered in whatever display currency the payer
// was viewing. New rows capture that currency and the rate frozen at settle time
// (#1445), so a settled position stays balanced when live rates drift — mirroring
@@ -678,12 +714,16 @@ export class BudgetService {
WHERE bp.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
`, tripId);
// Net balance per user, in the requested base currency: positive = is owed
// money, negative = owes money. Each expense's amounts are converted from their
// own currency to the base with live rates, so mixed-currency trips net correctly.
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
// Net balance per user, in whole cents of the TRIP currency: positive = is owed
// money, negative = owes money. Every amount is converted out of its own currency
// and rounded to a cent once, at the boundary — from there on the ledger is
// integer arithmetic, so Σ(balances) is exactly 0 and no sub-cent residual can
// build up behind the two-decimal figures the user sees (#1382).
const toTripCents = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number =>
Math.round(toTrip(amount, itemCurrency, itemRate) * 100);
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; cents: number }> = {};
const ensure = (id: number, src: { username?: string; avatar?: string | null }) => {
if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), balance: 0 };
if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), cents: 0 };
return balances[id];
};
@@ -694,16 +734,31 @@ export class BudgetService {
// Payers are credited what they actually paid (converted to trip currency with
// the item's stored exchange rate)…
for (const p of payers) ensure(p.user_id, p).balance += toTrip(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate);
let creditCents = 0;
for (const p of payers) {
const paid = toTripCents(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate);
ensure(p.user_id, p).cents += paid;
creditCents += paid;
}
// …and each split participant owes their share — a custom per-member amount
// when one is set, otherwise an equal share of the expense total.
// when one is set, otherwise an equal share of the expense.
//
// The equal split divides what the payers were actually credited, not
// total_price: the two are the same figure (the write path derives the total
// from the payer sum), but converting the total separately would round to a
// different cent on a foreign-currency expense and leave the item off by one.
// With nobody down as a payer there is nothing to divide, so the recorded total
// stands in and an unpaid bill keeps reading as money owed.
const hasCustomSplit = members.some(m => m.amount !== null && m.amount !== undefined);
const equalShares = !hasCustomSplit ? this.splitEqualShares(item.total_price, members, item.id) : {};
const splitCents = creditCents > 0
? creditCents
: toTripCents(item.total_price, item.currency, item.exchange_rate);
const equalShares = !hasCustomSplit ? this.splitEqualShares(splitCents, members, item.id) : {};
for (const m of members) {
const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined
? toTrip(m.amount, item.currency, item.exchange_rate)
: toTrip(equalShares[m.user_id] || 0, item.currency, item.exchange_rate);
ensure(m.user_id, m).balance -= memberShare;
? toTripCents(m.amount, item.currency, item.exchange_rate)
: (equalShares[m.user_id] || 0);
ensure(m.user_id, m).cents -= memberShare;
}
}
@@ -714,19 +769,29 @@ export class BudgetService {
// surfaces as an amount still to square up instead of silently vanishing.
const settlements = this.listSettlements(tripId);
const ensureSettled = (id: number, username: string | undefined, avatar_url: string | null | undefined) => {
if (!balances[id]) balances[id] = { user_id: id, username: username || '', avatar_url: avatar_url ?? null, balance: 0 };
if (!balances[id]) balances[id] = { user_id: id, username: username || '', avatar_url: avatar_url ?? null, cents: 0 };
return balances[id];
};
for (const s of settlements) {
const inTrip = settleToTrip(s.amount, s.currency, s.exchange_rate);
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += inTrip;
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= inTrip;
// Rounded to a trip cent per transfer, so recording one in a display currency
// can't leave a sliver of a cent behind to accumulate over a trip's lifetime.
const inTrip = Math.round(settleToTrip(s.amount, s.currency, s.exchange_rate) * 100);
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).cents += inTrip;
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).cents -= inTrip;
}
// Into the display currency as one set, then simplify — balances and flows are
// derived from the same integers, so what the balances say is owed is exactly
// what "Settle up" offers to move, down to the last cent (#1382).
const ledger = Object.values(balances);
const displayCents = allocateDisplayCents(ledger.map(b => b.cents), displayFactor);
// Calculate optimized payment flows (greedy algorithm)
const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));
const people = ledger
.map((b, i) => ({ user_id: b.user_id, username: b.username, avatar_url: b.avatar_url, cents: displayCents[i] }))
.filter(b => b.cents !== 0);
const debtors = people.filter(p => p.cents < 0).map(p => ({ ...p, amount: -p.cents }));
const creditors = people.filter(p => p.cents > 0).map(p => ({ ...p, amount: p.cents }));
// Sort by amount descending for efficient matching
debtors.sort((a, b) => b.amount - a.amount);
@@ -737,21 +802,22 @@ export class BudgetService {
let di = 0, ci = 0;
while (di < debtors.length && ci < creditors.length) {
const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
if (transfer > 0.01) {
flows.push({
from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
amount: Math.round(toDisplay(transfer) * 100) / 100,
});
}
flows.push({
from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
amount: transfer / 100,
});
debtors[di].amount -= transfer;
creditors[ci].amount -= transfer;
if (debtors[di].amount < 0.01) di++;
if (creditors[ci].amount < 0.01) ci++;
if (debtors[di].amount === 0) di++;
if (creditors[ci].amount === 0) ci++;
}
return {
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(toDisplay(b.balance) * 100) / 100 })),
balances: ledger.map((b, i) => ({
user_id: b.user_id, username: b.username, avatar_url: b.avatar_url,
balance: displayCents[i] / 100,
})),
flows,
settlements,
};
+27 -3
View File
@@ -313,7 +313,12 @@ export class CalendarService {
// A stay wins over everything below: it is the only source that knows how
// long the booking lasts. Emitted as an all-day range over every night, so
// the hotel sits above the day in a subscribed calendar instead of
// appearing once on the arrival day (#1586). DTEND is exclusive, hence +1.
// appearing once on the arrival day (#1586).
// end_day_id is the CHECK-OUT day, and the block is meant to cover it too:
// a 26th-to-30th booking should read as a five-day block, not as the four
// nights it contains. DTEND is exclusive, so it is check-out plus one day.
// Dropping that +1 hides the departure day; it is not a stray off-by-one
// (#1869).
if (isDate(r.stay_start_date)) {
const lastDay = isDate(r.stay_end_date) && r.stay_end_date >= r.stay_start_date
? r.stay_end_date
@@ -362,6 +367,18 @@ export class CalendarService {
}
return out;
}
// Date-only start. RFC 5545 makes a DATE event without DTEND exactly one
// day long, so a booking that does record an end date still collapsed
// onto its first day (#1869); an all-day multi-day event coming through
// the booking import (kitinerary-mapper passes date-only start/end
// straight through) is the common shape. Same rule as the stay branch:
// DTEND is exclusive, hence the end day plus one. Only the date part is
// used, because DTSTART;VALUE=DATE and a timed DTEND may not be mixed.
const endDatePart = r.reservation_end_time ? String(r.reservation_end_time).split('T')[0] : '';
if (isDate(endDatePart) && endDatePart >= r.reservation_time) {
return `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n` +
`DTEND;VALUE=DATE:${fmtDate(addDays(endDatePart, 1))}\r\n`;
}
return `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
}
@@ -414,16 +431,23 @@ export class CalendarService {
// all-day event cannot carry a time, and "be there at 15:00" is the part a
// subscriber actually wants a reminder for. Emitted per stay rather than per
// reservation so a stay whose reservation was deleted still shows them.
// The reservation is read through a subquery rather than a LEFT JOIN: nothing
// stops two bookings from pointing at the same accommodation, and a join fans
// the stay out into one row per booking. Since the check-in/check-out UIDs are
// keyed by the stay, that emitted the same UID twice and clients then pick one
// of the duplicates at random (#1869). Lowest id wins so the title is stable
// across exports.
const stays = this.db.prepare(`
SELECT a.id, a.check_in, a.check_in_end, a.check_out,
sd.date AS start_date, ed.date AS end_date,
p.name AS place_name, p.address AS place_address, p.lat AS place_lat, p.lng AS place_lng,
r.title AS reservation_title
(SELECT r.title FROM reservations r
WHERE r.accommodation_id = a.id
ORDER BY r.id ASC LIMIT 1) AS reservation_title
FROM day_accommodations a
LEFT JOIN days sd ON a.start_day_id = sd.id
LEFT JOIN days ed ON a.end_day_id = ed.id
LEFT JOIN places p ON a.place_id = p.id
LEFT JOIN reservations r ON r.accommodation_id = a.id
WHERE a.trip_id = ?
ORDER BY a.id ASC
`).all(tripId) as any[];
@@ -203,7 +203,7 @@ export class CollectionsMcp {
@Tool({
name: 'update_collection_place',
description: 'Update a saved place\'s name, description, notes, status, category, links, tags, labels, image, or move it to another collection (set collection_id).',
description: 'Update a saved place\'s name, address, coordinates (lat/lng), description, notes, status, category, links, tags, labels, image, or move it to another collection (set collection_id).',
inputSchema: { placeId: z.number().int().positive(), ...collectionPlaceUpdateRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_WRITE,
when: collectionsAddonOn,
@@ -653,6 +653,7 @@ export class CollectionsService {
if (body.notes !== undefined) { updates.push('notes = ?'); params.push(body.notes ?? null); }
if (body.lat !== undefined) { updates.push('lat = ?'); params.push(body.lat ?? null); }
if (body.lng !== undefined) { updates.push('lng = ?'); params.push(body.lng ?? null); }
if (body.address !== undefined) { updates.push('address = ?'); params.push(body.address ?? null); }
if (body.status !== undefined) { updates.push('status = ?'); params.push(body.status); }
if (body.category_id !== undefined) { updates.push('category_id = ?'); params.push(body.category_id ?? null); }
if (body.image_url !== undefined) { updates.push('image_url = ?'); params.push(body.image_url ?? null); }
@@ -25,7 +25,8 @@ export class LlmConfigResolver {
/**
* Resolve the effective LLM config for a user, gated by the addon.
* Order: addon disabled → null; admin instance config wins; else per-user config;
* else null. This is the single place the API key is decrypted.
* else null. This is the single place the API key is decrypted, and the single
* place that decides which endpoint the server is allowed to call (#1772).
*/
resolve(userId: number): ResolvedLlmConfig | null {
if (!this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING)) return null;
@@ -61,14 +62,31 @@ export class LlmConfigResolver {
const provider = asProvider(settings.llm_provider);
const model = typeof settings.llm_model === 'string' ? settings.llm_model.trim() : '';
if (!provider || !model) return null;
// #1772: the address this server calls is instance configuration, never a
// personal preference. The request leaves OUR network and safeFetchLlm
// deliberately allows loopback/LAN so a self-hosted Ollama keeps working,
// which is a reasonable trade for whoever runs the instance and a network
// probe for anyone else. An instance has exactly one such address, so it
// comes from the admin-set instance-wide defaults for EVERY user, including
// an admin's own row. This is the choke point every consumer passes
// (booking import and the plugin RPC surface), and the only place that also
// catches values already sitting in the db.
const endpoints = this.settings.getAdminUserDefaults();
// 'local' is an endpoint choice too ("some address I name"), so without an
// admin-set local endpoint there is no config at all, never a silent
// redirect to a different provider.
if (provider === 'local' && asProvider(endpoints.llm_provider) !== 'local') return null;
const baseUrl =
typeof endpoints.llm_base_url === 'string' && endpoints.llm_base_url.trim()
? endpoints.llm_base_url.trim()
: undefined;
const apiKey = this.settings.getDecryptedUserSetting(userId, 'llm_api_key') ?? undefined;
return {
provider,
model,
baseUrl:
typeof settings.llm_base_url === 'string' && settings.llm_base_url.trim()
? settings.llm_base_url.trim()
: undefined,
baseUrl,
apiKey,
multimodal: settings.llm_multimodal === true,
};
+295 -19
View File
@@ -13,8 +13,11 @@ import { DaysService } from '../days/days.service';
import { findByIata } from '../airports/airports.data';
import type { EndpointInput } from './reservations.service';
import { AssignmentsService } from '../assignments/assignments.service';
import { transportLegsInputSchema, type TransportLegInput } from '@trek/shared';
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const;
/** Only these two carry per-segment detail; a car or cruise has no legs. */
const LEG_TRANSPORT_TYPES = ['flight', 'train'] as const;
const endpointObjectSchema = z.object({
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
@@ -72,6 +75,205 @@ function parseId(value: string | string[]): number | null {
return Number.isInteger(n) && n > 0 ? n : null;
}
// ---------------------------------------------------------------------------
// Multi-leg bookings (#1914)
//
// A stopover booking splits over two stores: the endpoints hold the geometry,
// `metadata.legs` holds each segment's own day, time and airline/train identity.
// The endpoint of a stop carries the ONWARD DEPARTURE only (the planner form
// writes `isLast ? arrTime : depTime`), so without legs the arrival at that stop
// exists nowhere and every reader falls back to showing one time twice, which is
// the symptom in the report. These helpers accept the same leg shape the form
// and the importers write, and keep the two stores in step.
// ---------------------------------------------------------------------------
const legsSchema = transportLegsInputSchema.optional().describe(
'Per-segment detail of a stopover flight or train: one entry per segment, in route order, exactly ONE FEWER than endpoints[]. '
+ 'Each leg carries its own departure and arrival day + local time (dep_day_id/dep_time, arr_day_id/arr_time) plus airline/flight_number (flights) or train_number/platform (trains). '
+ "Required for a booking with stopovers: a stop's endpoint stores only the onward departure, so without legs that one time is shown as both the arrival and the departure. "
+ 'Omit it for a direct booking. Blank endpoint times/dates, day_id/end_day_id and reservation_time are derived from the legs; a value that contradicts them is rejected.'
);
type MetaRecord = Record<string, unknown>;
/**
* `reservations.metadata` as stored: a JSON string, or (from an old
* double-encoding bug the client readers still heal around) a JSON string of a
* JSON string. Reading it must never throw: a booking with unparseable metadata
* still has to accept a legs update.
*/
function parseStoredMetadata(raw: unknown): MetaRecord {
if (typeof raw !== 'string' || !raw) return {};
try {
let parsed: unknown = JSON.parse(raw);
if (typeof parsed === 'string') parsed = JSON.parse(parsed);
return typeof parsed === 'object' && parsed !== null ? parsed as MetaRecord : {};
} catch {
return {};
}
}
interface LegPlan {
legs: TransportLegInput[];
type: string;
/** Resolved geometry the legs run over (one more entry than legs). */
endpoints: EndpointInput[];
baseMetadata: MetaRecord;
/** Stored legs of the same booking, for the day-planner positions. */
previousLegs: unknown[];
startDayId?: number;
endDayId?: number;
reservationTime?: string;
reservationEndTime?: string;
// A day row carries an optional date, so the caller cannot promise one. Every
// read below treats a dateless day as "no date known" rather than stamping undefined.
lookupDay: (id: number) => { date?: string | null } | undefined;
}
interface LegOutcome {
metadata: MetaRecord;
endpoints: EndpointInput[];
/** True when a blank endpoint time/date was filled in from the legs. */
endpointsChanged: boolean;
day_id?: number;
end_day_id?: number;
reservation_time?: string;
reservation_end_time?: string;
}
/**
* Validate the legs against the endpoints and fold them into the metadata.
* Returns the values the write should use, or the first error as text.
*/
function applyLegs(plan: LegPlan): LegOutcome | { error: string } {
const { legs, lookupDay } = plan;
const last = legs.length - 1;
if (!(LEG_TRANSPORT_TYPES as readonly string[]).includes(plan.type))
return { error: 'legs are only supported for flight and train bookings.' };
// Readers treat legs.length > 1 as the multi-segment marker and the form drops
// a single-leg list on the next save, so a 1-entry list would be inert.
if (legs.length < 2)
return { error: 'legs describes a booking with at least one stopover, so it needs at least 2 entries. Omit legs for a direct booking.' };
if (plan.endpoints.length !== legs.length + 1)
return { error: `legs must contain exactly one entry fewer than endpoints (got ${legs.length} legs for ${plan.endpoints.length} endpoints).` };
for (let i = 0; i < legs.length; i++) {
for (const field of ['dep_day_id', 'arr_day_id'] as const) {
const dayId = legs[i][field];
if (dayId != null && !lookupDay(dayId))
return { error: `legs[${i}].${field} does not belong to this trip.` };
}
}
const firstDepDay = legs[0].dep_day_id ?? null;
const lastArrDay = legs[last].arr_day_id ?? null;
if (plan.startDayId != null && firstDepDay != null && plan.startDayId !== firstDepDay)
return { error: `start_day_id (${plan.startDayId}) does not match legs[0].dep_day_id (${firstDepDay}).` };
if (plan.endDayId != null && lastArrDay != null && plan.endDayId !== lastArrDay)
return { error: `end_day_id (${plan.endDayId}) does not match legs[${last}].arr_day_id (${lastArrDay}).` };
const endpoints = plan.endpoints.map(e => ({ ...e }));
let endpointsChanged = false;
const syncEndpoint = (index: number, time: string | null | undefined, dayId: number | null | undefined, label: string): string | null => {
const ep = endpoints[index];
if (dayId != null && !ep.local_date) {
const day = lookupDay(dayId);
if (day?.date) { ep.local_date = day.date; endpointsChanged = true; }
}
if (!time) return null;
if (!ep.local_time) { ep.local_time = time; endpointsChanged = true; return null; }
if (ep.local_time !== time)
return `${label} (${time}) does not match endpoints[${index}].local_time (${ep.local_time}). A stop's endpoint carries the onward departure time; only the final endpoint carries an arrival time.`;
return null;
};
const merged: MetaRecord[] = [];
for (let i = 0; i < legs.length; i++) {
const leg = legs[i];
const depEp = endpoints[i];
const arrEp = endpoints[i + 1];
// Codes only: a train endpoint is a free-text station label, and comparing
// those would reject perfectly good input over a spelling difference.
if (leg.from && depEp.code && leg.from.toUpperCase() !== depEp.code.toUpperCase())
return { error: `legs[${i}].from (${leg.from}) does not match endpoints[${i}] (${depEp.code}).` };
if (leg.to && arrEp.code && leg.to.toUpperCase() !== arrEp.code.toUpperCase())
return { error: `legs[${i}].to (${leg.to}) does not match endpoints[${i + 1}] (${arrEp.code}).` };
const depConflict = syncEndpoint(i, leg.dep_time, leg.dep_day_id, `legs[${i}].dep_time`);
if (depConflict) return { error: depConflict };
const entry: MetaRecord = {
from: leg.from ?? depEp.code ?? depEp.name,
to: leg.to ?? arrEp.code ?? arrEp.name,
};
for (const key of ['airline', 'flight_number', 'train_number', 'platform', 'seat'] as const) {
const value = leg[key];
if (value) entry[key] = value;
}
entry.dep_day_id = leg.dep_day_id ?? null;
entry.dep_time = leg.dep_time ?? null;
entry.arr_day_id = leg.arr_day_id ?? null;
entry.arr_time = leg.arr_time ?? null;
// day_positions belongs to the day planner, not to this input, so carry the
// stored value at the same index over, exactly as a form re-save does.
const positions = (plan.previousLegs[i] as MetaRecord | undefined)?.day_positions;
if (positions) entry.day_positions = positions;
merged.push(entry);
}
const arrConflict = syncEndpoint(legs.length, legs[last].arr_time, lastArrDay, `legs[${last}].arr_time`);
if (arrConflict) return { error: arrConflict };
const metadata: MetaRecord = { ...plan.baseMetadata, legs: merged };
const first = merged[0];
const final = merged[last];
// Mirror the flat keys off the first/last leg the way the form and the
// importers do, so readers that never learned about legs keep working. Never
// overwrite what the caller set itself.
const mirror = (key: string, value: unknown) => {
if (value != null && metadata[key] == null) metadata[key] = value;
};
if (plan.type === 'flight') {
mirror('departure_airport', first.from);
mirror('arrival_airport', final.to);
mirror('airline', first.airline);
mirror('flight_number', first.flight_number);
} else {
mirror('train_number', first.train_number);
mirror('platform', first.platform);
}
mirror('seat', first.seat);
const dayId = plan.startDayId ?? firstDepDay ?? undefined;
const endDayId = plan.endDayId ?? lastArrDay ?? undefined;
// Same fallback as the form's buildTime: date the time when the day is known,
// otherwise keep the bare 'HH:mm' rather than dropping it.
const stamp = (day: number | undefined, time: string | null | undefined) => {
if (!time) return undefined;
const row = day === undefined ? undefined : lookupDay(day);
return row?.date ? `${row.date}T${time}` : time;
};
return {
metadata,
endpoints,
endpointsChanged,
day_id: dayId,
end_day_id: endDayId,
reservation_time: plan.reservationTime ?? stamp(dayId, legs[0].dep_time),
reservation_end_time: plan.reservationEndTime ?? stamp(endDayId, legs[last].arr_time),
};
}
/**
* `metadata` is a flat string map, so segments smuggled through it can only ever
* land as an inert JSON string that every reader skips (`Array.isArray` fails),
* the silent dead end reported in #1914. Refuse it and name the right parameter.
*/
const LEGS_IN_METADATA_ERROR =
'Pass the segments of a multi-leg booking in the legs parameter, not in metadata.legs. Metadata values are plain strings, so a JSON string there is stored but ignored by every reader.';
/**
* Reservations MCP surface — ported 1:1 from the legacy registrars: the five
* tools from src/mcp/tools/reservations.ts and the
@@ -361,7 +563,7 @@ export class ReservationsMcp {
@Tool({
name: 'create_transport',
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: {
tripId: z.number().int().positive(),
type: z.enum(['flight', 'train', 'car', 'cruise']),
@@ -373,8 +575,9 @@ export class ReservationsMcp {
reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'),
confirmation_number: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }. Values are plain strings, so per-segment detail belongs in legs[], not here.'),
endpoints: endpointSchema,
legs: legsSchema,
needs_review: z.boolean().optional(),
price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'),
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'),
@@ -383,11 +586,11 @@ export class ReservationsMcp {
access: { group: 'reservations', mode: 'write' },
})
async createTransport(
{ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }: {
{ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, legs, needs_review, price, budget_category }: {
tripId: number; type: 'flight' | 'train' | 'car' | 'cruise'; title: string;
status?: 'pending' | 'confirmed' | 'cancelled'; start_day_id?: number; end_day_id?: number;
reservation_time?: string; reservation_end_time?: string; confirmation_number?: string; notes?: string;
metadata?: Record<string, string>; endpoints?: TransportEndpoint[]; needs_review?: boolean;
metadata?: Record<string, string>; endpoints?: TransportEndpoint[]; legs?: TransportLegInput[]; needs_review?: boolean;
price?: number; budget_category?: string;
},
ctx: McpContext,
@@ -396,6 +599,8 @@ export class ReservationsMcp {
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied();
if (metadata && 'legs' in metadata) return errorResult(LEGS_IN_METADATA_ERROR);
if (start_day_id && !this.days.getDay(start_day_id, tripId))
return errorResult('start_day_id does not belong to this trip.');
if (end_day_id && !this.days.getDay(end_day_id, tripId))
@@ -404,22 +609,50 @@ export class ReservationsMcp {
const resolved = resolveEndpointCoords(endpoints);
if ('error' in resolved) return errorResult(resolved.error);
const meta: Record<string, string> = { ...(metadata ?? {}) };
const meta: Record<string, unknown> = { ...(metadata ?? {}) };
let transportEndpoints = resolved.endpoints;
let dayId = start_day_id;
let spanEndDayId = end_day_id;
let departureTime = reservation_time;
let arrivalTime = reservation_end_time;
if (legs !== undefined) {
const applied = applyLegs({
legs,
type,
endpoints: resolved.endpoints,
baseMetadata: meta,
previousLegs: [],
startDayId: start_day_id,
endDayId: end_day_id,
reservationTime: reservation_time,
reservationEndTime: reservation_end_time,
lookupDay: (id) => this.days.getDay(id, tripId),
});
if ('error' in applied) return errorResult(applied.error);
Object.assign(meta, applied.metadata);
transportEndpoints = applied.endpoints;
dayId = applied.day_id;
spanEndDayId = applied.end_day_id;
departureTime = applied.reservation_time;
arrivalTime = applied.reservation_end_time;
}
if (price != null) meta.price = String(price);
const { reservation } = this.reservations.create(tripId, {
title,
type,
reservation_time,
reservation_end_time,
reservation_time: departureTime,
reservation_end_time: arrivalTime,
location: undefined,
confirmation_number,
notes,
day_id: start_day_id,
end_day_id: end_day_id ?? start_day_id,
day_id: dayId,
end_day_id: spanEndDayId ?? dayId,
status: status ?? 'pending',
metadata: Object.keys(meta).length > 0 ? meta : undefined,
endpoints: resolved.endpoints,
endpoints: transportEndpoints,
needs_review,
});
@@ -438,7 +671,7 @@ export class ReservationsMcp {
@Tool({
name: 'update_transport',
description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates). Use status "confirmed" to confirm.',
description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates), and legs[] to write the per-segment times of a stopover booking. Sending legs[] without metadata keeps the stored metadata (departure_airport, airtrail_ids, transit) and only replaces the segments. Use status "confirmed" to confirm.',
inputSchema: {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
@@ -451,19 +684,20 @@ export class ReservationsMcp {
reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'),
confirmation_number: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }. Replaces the stored metadata wholesale. Values are plain strings, so per-segment detail belongs in legs[], not here.'),
endpoints: endpointSchema,
legs: legsSchema,
needs_review: z.boolean().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'reservations', mode: 'write' },
})
async updateTransport(
{ tripId, reservationId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }: {
{ tripId, reservationId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, legs, needs_review }: {
tripId: number; reservationId: number; type?: 'flight' | 'train' | 'car' | 'cruise'; title?: string;
status?: 'pending' | 'confirmed' | 'cancelled'; start_day_id?: number; end_day_id?: number;
reservation_time?: string; reservation_end_time?: string; confirmation_number?: string; notes?: string;
metadata?: Record<string, string>; endpoints?: TransportEndpoint[]; needs_review?: boolean;
metadata?: Record<string, string>; endpoints?: TransportEndpoint[]; legs?: TransportLegInput[]; needs_review?: boolean;
},
ctx: McpContext,
) {
@@ -471,6 +705,8 @@ export class ReservationsMcp {
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied();
if (metadata && 'legs' in metadata) return errorResult(LEGS_IN_METADATA_ERROR);
const existing = this.reservations.getReservation(reservationId, tripId);
if (!existing) return errorResult('Transport not found.');
@@ -491,17 +727,57 @@ export class ReservationsMcp {
resolvedEndpoints = resolved.endpoints;
}
let nextMetadata: unknown = metadata;
let dayId = start_day_id;
let spanEndDayId: number | undefined = end_day_id;
let departureTime = reservation_time;
let arrivalTime = reservation_end_time;
if (legs !== undefined) {
const stored = parseStoredMetadata(existing.metadata);
const applied = applyLegs({
legs,
type: resolvedType,
// Endpoints the caller did not replace stay the geometry the legs run
// over, so read them back (the row carries them) instead of guessing.
endpoints: resolvedEndpoints ?? (this.reservations.getReservationWithJoins(reservationId)?.endpoints ?? []).map(e => ({
role: e.role, sequence: e.sequence, name: e.name, code: e.code,
lat: e.lat, lng: e.lng, timezone: e.timezone,
local_time: e.local_time, local_date: e.local_date,
})),
// metadata keeps replacing wholesale when it is sent; a legs-only update
// must not drop departure_airport / airtrail_ids / transit, so it merges
// into what is stored instead.
baseMetadata: metadata !== undefined ? { ...metadata } : { ...stored },
previousLegs: Array.isArray(stored.legs) ? stored.legs as unknown[] : [],
startDayId: start_day_id,
endDayId: end_day_id,
reservationTime: reservation_time,
reservationEndTime: reservation_end_time,
lookupDay: (id) => this.days.getDay(id, tripId),
});
if ('error' in applied) return errorResult(applied.error);
nextMetadata = applied.metadata;
dayId = applied.day_id;
spanEndDayId = applied.end_day_id;
departureTime = applied.reservation_time;
arrivalTime = applied.reservation_end_time;
// Rewrite the endpoints only when the legs actually filled a blank time or
// date in; an unchanged list is left alone so its rows survive.
if (applied.endpointsChanged) resolvedEndpoints = applied.endpoints;
}
const { reservation } = this.reservations.update(reservationId, tripId, {
title,
type,
reservation_time,
reservation_end_time,
reservation_time: departureTime,
reservation_end_time: arrivalTime,
confirmation_number,
notes,
day_id: start_day_id,
end_day_id,
day_id: dayId,
end_day_id: spanEndDayId,
status,
metadata,
metadata: nextMetadata,
endpoints: resolvedEndpoints,
needs_review,
}, existing);
@@ -2,7 +2,7 @@ import { Body, Controller, Get, HttpCode, HttpException, Post, Put, Req, UseGuar
import type { Request } from 'express';
import { MASKED_SETTING_VALUE } from '@trek/shared';
import type { User } from '../../types';
import { SettingsService } from './settings.service';
import { SettingsService, isAdminOnlyLlmSetting } from './settings.service';
import { SettingUpsertDto, SettingsBulkDto } from './settings.dto';
import { AdminDefaultUserSettingsDto } from '../admin/admin.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -18,12 +18,37 @@ import { CurrentUser } from '../auth/current-user.decorator';
* the DTO classes in settings.dto.ts + the global ZodValidationPipe (400 with
* the standard `{ error }` envelope on mismatch — this replaced the legacy
* bespoke 'Key is required' / 'Settings object is required' checks).
*
* One key group is authorized rather than merely validated: the LLM endpoint
* settings answer 403 here for everyone, admins included, because they are
* instance configuration and live on their own admin routes (#1772).
*/
@Controller('api/settings')
@UseGuards(JwtAuthGuard)
export class SettingsController {
constructor(private readonly settings: SettingsService) {}
/**
* #1772: the write half of the rule that the endpoint is instance
* configuration. There is no key allow-list on this route, so anyone could
* otherwise park an `llm_base_url` (or `llm_provider: 'local'`) in their own
* settings row. The resolver ignores such a row anyway; refusing it here
* means the caller finds out instead of silently saving something that never
* takes effect.
*
* Admins are not exempt: they set the instance endpoint on the addon, or via
* PUT /api/admin/default-user-settings, both of which are separate routes
* behind AdminGuard. A personal row would be a second, invisible place for
* the same value.
*/
private assertMayWriteLlmEndpoint(settings: Record<string, unknown>) {
for (const [key, value] of Object.entries(settings)) {
if (isAdminOnlyLlmSetting(key, value)) {
throw new HttpException({ error: 'Admin access required' }, 403);
}
}
}
@Get()
list(@CurrentUser() user: User) {
return { settings: this.settings.getUserSettings(user.id) };
@@ -31,6 +56,7 @@ export class SettingsController {
@Put()
upsert(@CurrentUser() user: User, @Body() body: SettingUpsertDto) {
this.assertMayWriteLlmEndpoint({ [body.key]: body.value });
// The client echoes a redacted secret back unchanged — treat as a no-op.
if (body.value === MASKED_SETTING_VALUE) {
return { success: true, key: body.key, unchanged: true };
@@ -42,6 +68,7 @@ export class SettingsController {
@Post('bulk')
@HttpCode(200) // Express answers bulk with res.json (200), not the POST-default 201.
bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto) {
this.assertMayWriteLlmEndpoint(body.settings);
try {
const updated = this.settings.bulkUpsertSettings(user.id, body.settings);
return { success: true, updated };
@@ -50,6 +50,23 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode', 'llm_multimodal']);
/**
* #1772: per-user LLM settings a non-admin must not write. Both of them pick
* the address this server sends its own LLM requests to (provider 'local' means
* nothing but "an endpoint I name"), and the SSRF guard in front of those
* requests allows loopback/LAN on purpose so a self-hosted Ollama works. Only
* whoever runs the instance can judge what is reachable from it.
*
* Clearing stays open to everyone: both Settings sections always send
* `llm_base_url: ''` while the field is hidden, so a row written before this
* rule cleans itself up on the next save instead of blocking it.
*/
export function isAdminOnlyLlmSetting(key: string, value: unknown): boolean {
if (key === 'llm_base_url') return typeof value === 'string' && value.trim() !== '';
if (key === 'llm_provider') return value === 'local';
return false;
}
function parseValue(raw: string): unknown {
try { return JSON.parse(raw); } catch { return raw; }
}
+28
View File
@@ -154,6 +154,34 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
expect(db.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId)).toEqual({ status: 'want' });
});
// #1870: the address was missing from the update contract, so the pipe stripped
// it and the column never moved. A saved address was effectively read-only.
it('COLLECTIONS-E2E-013: PATCH address corrects a saved place and survives the validation pipe', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Addresses' })).body;
const saved = await request(server).post('/api/addons/collections/places')
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Trattoria', address: 'Via Vechia 1' });
expect(saved.status).toBe(200);
const placeId = saved.body.place.id;
const patched = await request(server).patch(`/api/addons/collections/places/${placeId}`)
.set('Cookie', sessionCookie(ownerId)).send({ address: 'Via Nuova 1' });
expect(patched.status).toBe(200);
expect(patched.body.address).toBe('Via Nuova 1');
expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: 'Via Nuova 1' });
// A rename must not wipe the address that was just corrected.
const renamed = await request(server).patch(`/api/addons/collections/places/${placeId}`)
.set('Cookie', sessionCookie(ownerId)).send({ name: 'Trattoria da Enzo' });
expect(renamed.status).toBe(200);
expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: 'Via Nuova 1' });
// null clears it again.
const cleared = await request(server).patch(`/api/addons/collections/places/${placeId}`)
.set('Cookie', sessionCookie(ownerId)).send({ address: null });
expect(cleared.status).toBe(200);
expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: null });
});
// ── Cross-user isolation ─────────────────────────────────────────────────
it('COLLECTIONS-E2E-020: a stranger gets 404 on someone else’s collection', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Private' })).body;
+71
View File
@@ -48,6 +48,7 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => {
beforeAll(async () => {
seedUser(db as never, { id: 1 });
seedUser(db as never, { id: 2, role: 'admin', email: 'e2e-admin@example.test' });
app = await build();
server = app.getHttpServer();
});
@@ -119,4 +120,74 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => {
expect(res.body).toEqual({ success: true, updated: 1 });
expect(db.prepare("SELECT value FROM settings WHERE user_id = 1 AND key = 'ntfy_token'").get()).toEqual({ value: 'tok-real' });
});
// #1772: a free-form LLM endpoint is admin-only. The resolver ignores such a
// row for a non-admin either way; these routes refuse it so the user is told.
describe('LLM endpoint settings are admin-only (#1772)', () => {
const countRows = () => (db.prepare('SELECT COUNT(*) AS n FROM settings').get() as { n: number }).n;
it('PUT 403 for a non-admin naming a base URL', async () => {
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
.send({ key: 'llm_base_url', value: 'http://192.168.1.5:11434' });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Admin access required' });
expect(countRows()).toBe(0);
});
it('PUT 403 for a non-admin picking the local provider', async () => {
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
.send({ key: 'llm_provider', value: 'local' });
expect(res.status).toBe(403);
expect(countRows()).toBe(0);
});
it('PUT lets a non-admin clear the base URL and pick a hosted provider', async () => {
expect((await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
.send({ key: 'llm_base_url', value: ' ' })).status).toBe(200);
expect((await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
.send({ key: 'llm_provider', value: 'anthropic' })).status).toBe(200);
expect(db.prepare("SELECT value FROM settings WHERE user_id = 1 AND key = 'llm_provider'").get())
.toEqual({ value: 'anthropic' });
});
it('PUT is unaffected for a non-LLM key with the same value', async () => {
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1))
.send({ key: 'start_page', value: 'local' });
expect(res.status).toBe(200);
});
it('POST /bulk 403 for a non-admin, and nothing in the payload is written', async () => {
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1))
.send({ settings: { llm_provider: 'local', llm_base_url: 'http://192.168.1.5:11434', llm_model: 'nuextract' } });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Admin access required' });
expect(countRows()).toBe(0);
});
it('POST /bulk 200 for a non-admin bringing their own hosted key', async () => {
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1))
.send({ settings: { llm_provider: 'anthropic', llm_base_url: '', llm_model: 'claude-sonnet' } });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, updated: 3 });
});
it('POST /bulk 403 for an admin too, since this is not where the endpoint lives', async () => {
// An instance has one endpoint and it is set on the addon, or through
// PUT /api/admin/default-user-settings. A personal row would be a second,
// invisible place for the same value, so the route refuses it for everyone.
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(2))
.send({ settings: { llm_provider: 'local', llm_base_url: 'http://192.168.1.5:11434' } });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Admin access required' });
expect(countRows()).toBe(0);
});
it('POST /bulk 200 for an admin bringing their own hosted key', async () => {
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(2))
.send({ settings: { llm_provider: 'openai', llm_base_url: '', llm_model: 'gpt-4o-mini' } });
expect(res.status).toBe(200);
expect(db.prepare("SELECT value FROM settings WHERE user_id = 2 AND key = 'llm_provider'").get())
.toEqual({ value: 'openai' });
});
});
});
+68
View File
@@ -201,6 +201,50 @@ describe('Bucket list', () => {
expect(res.status).toBe(400);
});
it('ATLAS-005 — POST the same item twice returns 409 and keeps one row (#1898)', async () => {
const { user } = createUser(testDb);
const body = { name: 'Japan', country_code: 'JP' };
const first = await request(app)
.post('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id))
.send(body);
expect(first.status).toBe(201);
const second = await request(app)
.post('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id))
.send(body);
expect(second.status).toBe(409);
expect(second.body).toEqual({ error: 'Already on your bucket list' });
const list = await request(app)
.get('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id));
expect(list.body.items).toHaveLength(1);
});
it('ATLAS-005 — POST the same item for another target date is allowed (#1898)', async () => {
const { user } = createUser(testDb);
const undated = await request(app)
.post('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id))
.send({ name: 'Japan', country_code: 'JP', target_date: null });
expect(undated.status).toBe(201);
const dated = await request(app)
.post('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id))
.send({ name: 'Japan', country_code: 'JP', target_date: '2027-05' });
expect(dated.status).toBe(201);
const list = await request(app)
.get('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id));
expect(list.body.items).toHaveLength(2);
});
it('ATLAS-006 — GET /bucket-list returns items', async () => {
const { user } = createUser(testDb);
@@ -233,6 +277,30 @@ describe('Bucket list', () => {
expect(res.body.item.name).toBe('New Name');
});
it('ATLAS-007 — PUT onto an existing wish returns 409 and leaves the row alone (#1898)', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id))
.send({ name: 'Japan', country_code: 'JP', target_date: '2027-05' });
const second = await request(app)
.post('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id))
.send({ name: 'Japan', country_code: 'JP', target_date: '2028-09' });
const res = await request(app)
.put(`/api/addons/atlas/bucket-list/${second.body.item.id}`)
.set('Cookie', authCookie(user.id))
.send({ target_date: '2027-05' });
expect(res.status).toBe(409);
expect(res.body).toEqual({ error: 'Already on your bucket list' });
const list = await request(app)
.get('/api/addons/atlas/bucket-list')
.set('Cookie', authCookie(user.id));
expect(list.body.items.map((i: { target_date: string }) => i.target_date).sort()).toEqual(['2027-05', '2028-09']);
});
it('ATLAS-008 — DELETE /bucket-list/:id removes item', async () => {
const { user } = createUser(testDb);
@@ -295,6 +295,22 @@ describe('Tool: update_bucket_list_item', () => {
});
});
it('returns isError when the edit would land on another wish (#1898)', async () => {
const { user } = createUser(testDb);
const insert = testDb.prepare('INSERT INTO bucket_list (user_id, name, target_date) VALUES (?, ?, ?)');
insert.run(user.id, 'Japan', '2027-05');
const itemId = insert.run(user.id, 'Japan', '2028-09').lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
arguments: { itemId, target_date: '2027-05' },
});
expect(result.isError).toBe(true);
const row = testDb.prepare('SELECT target_date FROM bucket_list WHERE id = ?').get(itemId) as { target_date: string };
expect(row.target_date).toBe('2028-09');
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
+30
View File
@@ -162,6 +162,36 @@ describe('Tool: create_bucket_list_item', () => {
});
});
it('takes a target date, so the same place can be planned for several dates (#1898)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const first = await h.client.callTool({
name: 'create_bucket_list_item',
arguments: { name: 'Japan', country_code: 'JP', target_date: '2027-05' },
});
expect((parseToolResult(first) as any).item.target_date).toBe('2027-05');
const second = await h.client.callTool({
name: 'create_bucket_list_item',
arguments: { name: 'Japan', country_code: 'JP', target_date: '2028-09' },
});
expect((parseToolResult(second) as any).item.target_date).toBe('2028-09');
});
});
it('reports the duplicate instead of appending a second row (#1898)', async () => {
const { user } = createUser(testDb);
createBucketListItem(testDb, user.id, { name: 'Japan', country_code: 'JP' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_bucket_list_item',
arguments: { name: 'Japan', country_code: 'JP' },
});
expect(result.isError).toBe(true);
expect((testDb.prepare('SELECT COUNT(*) AS n FROM bucket_list WHERE user_id = ?').get(user.id) as { n: number }).n).toBe(1);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
+564 -1
View File
@@ -38,7 +38,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { createUser, createTrip, createDay } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
@@ -66,6 +66,18 @@ const flightEndpoints = [
{ role: 'to', sequence: 1, name: 'Paris CDG', code: 'CDG' },
];
// The booking from the report (#1914): AMS → CDG → FCO with a stop in Paris.
const stopoverEndpoints = [
{ role: 'from', sequence: 0, name: 'Amsterdam (AMS)', code: 'AMS' },
{ role: 'stop', sequence: 1, name: 'Paris (CDG)', code: 'CDG' },
{ role: 'to', sequence: 2, name: 'Rome (FCO)', code: 'FCO' },
];
const errorText = (result: unknown) => (result as { content: { text: string }[] }).content[0].text;
const storedMetadata = (reservationId: number) => JSON.parse(
(testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservationId) as { metadata: string }).metadata
);
describe('Tool: create_transport', () => {
it('backfills lat/lng/timezone for code-only flight endpoints', async () => {
const { user } = createUser(testDb);
@@ -427,3 +439,554 @@ describe('Tool: delete_transport', () => {
});
});
});
// ---------------------------------------------------------------------------
// Multi-leg bookings (#1914)
//
// The per-segment times of a stopover live in metadata.legs; the endpoints only
// carry the onward departure. Before this the tools pinned metadata to string
// values and had no legs parameter, so a stopover created over MCP showed the
// stop's departure as its arrival too.
// ---------------------------------------------------------------------------
describe('Tool: create_transport (multi-leg)', () => {
it('stores the per-leg times of the reported AMS to CDG to FCO stopover', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id, { date: '2026-02-04' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO',
endpoints: stopoverEndpoints,
legs: [
{ from: 'AMS', to: 'CDG', dep_day_id: day.id, dep_time: '09:00', arr_time: '10:00' },
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
],
},
});
const data = parseToolResult(result) as any;
expect(storedMetadata(data.reservation.id).legs).toEqual([
{ from: 'AMS', to: 'CDG', dep_day_id: day.id, dep_time: '09:00', arr_day_id: null, arr_time: '10:00' },
{ from: 'CDG', to: 'FCO', dep_day_id: null, dep_time: '11:00', arr_day_id: null, arr_time: '12:00' },
]);
// Blank endpoint times are filled the way the form writes them: the onward
// departure on every waypoint but the last, the arrival on the last.
expect(data.reservation.endpoints.map((e: any) => e.local_time)).toEqual(['09:00', '11:00', '12:00']);
expect(data.reservation.endpoints[0].local_date).toBe('2026-02-04');
// The span follows the first and last leg.
expect(data.reservation.day_id).toBe(day.id);
expect(data.reservation.reservation_time).toBe('2026-02-04T09:00');
expect(data.reservation.reservation_end_time).toBe('12:00');
});
});
it('mirrors the flat metadata keys off the first and last leg', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const data = parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO',
endpoints: stopoverEndpoints,
legs: [
{ from: 'AMS', to: 'CDG', airline: 'KLM', flight_number: 'KL1233', seat: '14A' },
{ from: 'CDG', to: 'FCO', airline: 'Air France', flight_number: 'AF1204' },
],
},
})) as any;
const meta = storedMetadata(data.reservation.id);
expect(meta.departure_airport).toBe('AMS');
expect(meta.arrival_airport).toBe('FCO');
expect(meta.airline).toBe('KLM');
expect(meta.flight_number).toBe('KL1233');
expect(meta.seat).toBe('14A');
});
});
it('never overwrites a flat key the caller set itself', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const data = parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO',
metadata: { airline: 'KLM Cityhopper' },
endpoints: stopoverEndpoints,
legs: [
{ from: 'AMS', to: 'CDG', airline: 'KLM' },
{ from: 'CDG', to: 'FCO', airline: 'Air France' },
],
},
})) as any;
const meta = storedMetadata(data.reservation.id);
expect(meta.airline).toBe('KLM Cityhopper');
expect(meta.legs[0].airline).toBe('KLM');
});
});
it('fills from/to from the endpoint codes when the leg omits them', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const data = parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO',
endpoints: stopoverEndpoints,
legs: [{ dep_time: '09:00', arr_time: '10:00' }, { dep_time: '11:00', arr_time: '12:00' }],
},
})) as any;
const meta = storedMetadata(data.reservation.id);
expect(meta.legs.map((l: any) => [l.from, l.to])).toEqual([['AMS', 'CDG'], ['CDG', 'FCO']]);
});
});
it('accepts a multi-leg train and mirrors its flat keys', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const data = parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'train', title: 'Basel to Milano',
endpoints: [
{ role: 'from', sequence: 0, name: 'Basel SBB', lat: 47.5, lng: 7.6 },
{ role: 'stop', sequence: 1, name: 'Lugano', lat: 46.0, lng: 8.9 },
{ role: 'to', sequence: 2, name: 'Milano Centrale', lat: 45.5, lng: 9.2 },
],
legs: [
{ train_number: 'EC 57', platform: '8', dep_time: '08:33', arr_time: '11:20' },
{ train_number: 'EC 317', platform: '3', dep_time: '11:40', arr_time: '12:55' },
],
},
})) as any;
const meta = storedMetadata(data.reservation.id);
// Station endpoints carry no code, so the labels come from their names.
expect(meta.legs.map((l: any) => [l.from, l.to])).toEqual([
['Basel SBB', 'Lugano'], ['Lugano', 'Milano Centrale'],
]);
expect(meta.train_number).toBe('EC 57');
expect(meta.platform).toBe('8');
expect(data.reservation.endpoints.map((e: any) => e.local_time)).toEqual(['08:33', '11:40', '12:55']);
});
});
it('rejects a leg count that does not match the endpoints', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'ZRH to CDG',
endpoints: flightEndpoints,
legs: [{ from: 'ZRH', to: 'CDG' }, { from: 'CDG', to: 'FCO' }],
},
});
expect((result as { isError?: boolean }).isError).toBe(true);
expect(errorText(result)).toBe('legs must contain exactly one entry fewer than endpoints (got 2 legs for 2 endpoints).');
});
});
it('rejects a single leg, which no reader would treat as multi-segment', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'ZRH to CDG',
endpoints: flightEndpoints,
legs: [{ from: 'ZRH', to: 'CDG', dep_time: '09:00', arr_time: '10:00' }],
},
});
expect((result as { isError?: boolean }).isError).toBe(true);
expect(errorText(result)).toContain('at least 2 entries');
});
});
it('rejects legs on a transport type that has none', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'car', title: 'Rental',
legs: [{ from: 'A', to: 'B' }, { from: 'B', to: 'C' }],
},
});
expect((result as { isError?: boolean }).isError).toBe(true);
expect(errorText(result)).toBe('legs are only supported for flight and train bookings.');
});
});
it('rejects a leg day that belongs to another trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const otherTrip = createTrip(testDb, user.id);
const foreignDay = createDay(testDb, otherTrip.id, { date: '2026-02-04' });
await withHarness(user.id, async (h) => {
const depResult = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO',
endpoints: stopoverEndpoints,
legs: [{ from: 'AMS', to: 'CDG', dep_day_id: foreignDay.id }, { from: 'CDG', to: 'FCO' }],
},
});
expect(errorText(depResult)).toBe('legs[0].dep_day_id does not belong to this trip.');
const arrResult = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO',
endpoints: stopoverEndpoints,
legs: [{ from: 'AMS', to: 'CDG' }, { from: 'CDG', to: 'FCO', arr_day_id: foreignDay.id }],
},
});
expect(errorText(arrResult)).toBe('legs[1].arr_day_id does not belong to this trip.');
});
});
it('rejects a leg airport that contradicts its endpoint', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const wrongTo = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO',
endpoints: stopoverEndpoints,
legs: [{ from: 'AMS', to: 'FCO' }, { from: 'CDG', to: 'FCO' }],
},
});
expect(errorText(wrongTo)).toBe('legs[0].to (FCO) does not match endpoints[1] (CDG).');
const wrongFrom = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO',
endpoints: stopoverEndpoints,
legs: [{ from: 'AMS', to: 'CDG' }, { from: 'ORY', to: 'FCO' }],
},
});
expect(errorText(wrongFrom)).toBe('legs[1].from (ORY) does not match endpoints[1] (CDG).');
});
});
it('rejects a leg time that contradicts an endpoint time already set', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const withTimes = [
{ ...stopoverEndpoints[0], local_time: '09:00' },
{ ...stopoverEndpoints[1], local_time: '11:30' },
{ ...stopoverEndpoints[2], local_time: '12:30' },
];
await withHarness(user.id, async (h) => {
const depClash = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO', endpoints: withTimes,
legs: [
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:30' },
],
},
});
expect(errorText(depClash)).toContain('legs[1].dep_time (11:00) does not match endpoints[1].local_time (11:30)');
const arrClash = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO', endpoints: withTimes,
legs: [
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
{ from: 'CDG', to: 'FCO', dep_time: '11:30', arr_time: '12:00' },
],
},
});
expect(errorText(arrClash)).toContain('legs[1].arr_time (12:00) does not match endpoints[2].local_time (12:30)');
});
});
it('rejects a span that contradicts the first or last leg', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const dayOne = createDay(testDb, trip.id, { date: '2026-02-04' });
const dayTwo = createDay(testDb, trip.id, { date: '2026-02-05' });
await withHarness(user.id, async (h) => {
const startClash = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO', start_day_id: dayOne.id,
endpoints: stopoverEndpoints,
legs: [{ from: 'AMS', to: 'CDG', dep_day_id: dayTwo.id }, { from: 'CDG', to: 'FCO' }],
},
});
expect(errorText(startClash)).toBe(`start_day_id (${dayOne.id}) does not match legs[0].dep_day_id (${dayTwo.id}).`);
const endClash = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO', end_day_id: dayOne.id,
endpoints: stopoverEndpoints,
legs: [{ from: 'AMS', to: 'CDG' }, { from: 'CDG', to: 'FCO', arr_day_id: dayTwo.id }],
},
});
expect(errorText(endClash)).toBe(`end_day_id (${dayOne.id}) does not match legs[1].arr_day_id (${dayTwo.id}).`);
});
});
it('refuses segments smuggled through metadata.legs and names the legs parameter', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_transport',
arguments: {
tripId: trip.id, type: 'flight', title: 'AMS to FCO',
endpoints: stopoverEndpoints,
metadata: { legs: '[{"from":"AMS","to":"CDG"}]' },
},
});
expect((result as { isError?: boolean }).isError).toBe(true);
expect(errorText(result)).toContain('in the legs parameter, not in metadata.legs');
});
});
});
describe('Tool: update_transport (multi-leg)', () => {
const seedStopover = async (h: McpHarness, tripId: number) => parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: {
tripId, type: 'flight', title: 'AMS to FCO',
endpoints: stopoverEndpoints,
legs: [
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
],
},
})) as any;
it('keeps the stored metadata and the day-plan positions when only legs are sent', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = await seedStopover(h, trip.id);
// What an AirTrail import leaves behind: sync ids plus per-leg planner positions.
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify({
departure_airport: 'AMS', arrival_airport: 'FCO', airtrail_ids: ['17', '18'],
legs: [
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00', day_positions: { 5: 2 } },
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
],
}), created.reservation.id);
const result = await h.client.callTool({
name: 'update_transport',
arguments: {
tripId: trip.id, reservationId: created.reservation.id,
legs: [
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:15' },
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
],
},
});
const meta = storedMetadata((parseToolResult(result) as any).reservation.id);
expect(meta.airtrail_ids).toEqual(['17', '18']);
expect(meta.departure_airport).toBe('AMS');
expect(meta.legs[0].arr_time).toBe('10:15');
expect(meta.legs[0].day_positions).toEqual({ 5: 2 });
expect(meta.legs[1].day_positions).toBeUndefined();
});
});
it('uses the supplied metadata as the base when both are sent', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = await seedStopover(h, trip.id);
const result = await h.client.callTool({
name: 'update_transport',
arguments: {
tripId: trip.id, reservationId: created.reservation.id,
metadata: { confirmation_source: 'email' },
legs: [
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
],
},
});
const meta = storedMetadata((parseToolResult(result) as any).reservation.id);
expect(meta.confirmation_source).toBe('email');
expect(meta.legs).toHaveLength(2);
// departure_airport is mirrored back in because the supplied metadata dropped it.
expect(meta.departure_airport).toBe('AMS');
});
});
it('leaves the metadata alone when neither legs nor metadata are sent', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = await seedStopover(h, trip.id);
const before = storedMetadata(created.reservation.id);
await h.client.callTool({
name: 'update_transport',
arguments: { tripId: trip.id, reservationId: created.reservation.id, status: 'confirmed' },
});
expect(storedMetadata(created.reservation.id)).toEqual(before);
});
});
it('counts the stored endpoints when the caller does not replace them', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'flight', title: 'ZRH to CDG', endpoints: flightEndpoints },
})) as any;
const result = await h.client.callTool({
name: 'update_transport',
arguments: {
tripId: trip.id, reservationId: created.reservation.id,
legs: [{ from: 'ZRH', to: 'CDG' }, { from: 'CDG', to: 'FCO' }],
},
});
expect((result as { isError?: boolean }).isError).toBe(true);
expect(errorText(result)).toBe('legs must contain exactly one entry fewer than endpoints (got 2 legs for 2 endpoints).');
});
});
it('leaves the endpoint rows untouched when the legs change nothing about them', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = await seedStopover(h, trip.id);
const idsBefore = (testDb.prepare('SELECT id FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence')
.all(created.reservation.id) as any[]).map(r => r.id);
await h.client.callTool({
name: 'update_transport',
arguments: {
tripId: trip.id, reservationId: created.reservation.id,
legs: [
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:30' },
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
],
},
});
const idsAfter = (testDb.prepare('SELECT id FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence')
.all(created.reservation.id) as any[]).map(r => r.id);
expect(idsAfter).toEqual(idsBefore);
});
});
it('fills the endpoint times when the stored endpoints have none', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'flight', title: 'AMS to FCO', endpoints: stopoverEndpoints },
})) as any;
const result = await h.client.callTool({
name: 'update_transport',
arguments: {
tripId: trip.id, reservationId: created.reservation.id,
legs: [
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
],
},
});
const data = parseToolResult(result) as any;
expect(data.reservation.endpoints.map((e: any) => e.local_time)).toEqual(['09:00', '11:00', '12:00']);
});
});
it('survives a booking whose stored metadata is not valid JSON', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = await seedStopover(h, trip.id);
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run('not json at all', created.reservation.id);
const result = await h.client.callTool({
name: 'update_transport',
arguments: {
tripId: trip.id, reservationId: created.reservation.id,
legs: [
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
],
},
});
expect((result as { isError?: boolean }).isError).toBeFalsy();
expect(storedMetadata((parseToolResult(result) as any).reservation.id).legs).toHaveLength(2);
});
});
it('recovers legs from double-encoded metadata instead of losing the positions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = await seedStopover(h, trip.id);
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify(JSON.stringify({
legs: [{ from: 'AMS', to: 'CDG', day_positions: { 9: 1 } }, { from: 'CDG', to: 'FCO' }],
})), created.reservation.id);
const result = await h.client.callTool({
name: 'update_transport',
arguments: {
tripId: trip.id, reservationId: created.reservation.id,
legs: [
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
],
},
});
expect(storedMetadata((parseToolResult(result) as any).reservation.id).legs[0].day_positions).toEqual({ 9: 1 });
});
});
it('rejects legs on a booking that is not a flight or train', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = parseToolResult(await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'car', title: 'Rental' },
})) as any;
const result = await h.client.callTool({
name: 'update_transport',
arguments: {
tripId: trip.id, reservationId: created.reservation.id,
legs: [{ from: 'A', to: 'B' }, { from: 'B', to: 'C' }],
},
});
expect((result as { isError?: boolean }).isError).toBe(true);
expect(errorText(result)).toBe('legs are only supported for flight and train bookings.');
});
});
it('refuses segments smuggled through metadata.legs', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = await seedStopover(h, trip.id);
const result = await h.client.callTool({
name: 'update_transport',
arguments: {
tripId: trip.id, reservationId: created.reservation.id,
metadata: { legs: '[{"from":"AMS","to":"CDG"}]' },
},
});
expect((result as { isError?: boolean }).isError).toBe(true);
expect(errorText(result)).toContain('in the legs parameter, not in metadata.legs');
});
});
});
@@ -81,7 +81,10 @@ describe('AccommodationsController (parity with the legacy accommodations sub-ro
it('emits the linked reservation/budget cascade then accommodation:deleted', () => {
const get = vi.fn().mockReturnValue({ id: 9 });
const remove = vi.fn().mockReturnValue({ linkedReservationId: 4, deletedBudgetItemId: 7 });
const remove = vi.fn().mockReturnValue({
linkedReservationId: 4, deletedBudgetItemId: 7,
linkedReservationIds: [4], deletedBudgetItemIds: [7],
});
const broadcast = vi.fn();
const svc = makeService({ get, remove, broadcast } as Partial<AccommodationsService>);
expect(new AccommodationsController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
@@ -89,5 +92,18 @@ describe('AccommodationsController (parity with the legacy accommodations sub-ro
expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:deleted', { accommodationId: 9 }, 'sock');
});
it('emits one event per booking when a stay carried more than one (#1869)', () => {
const get = vi.fn().mockReturnValue({ id: 9 });
const remove = vi.fn().mockReturnValue({
linkedReservationId: 4, deletedBudgetItemId: null,
linkedReservationIds: [4, 5], deletedBudgetItemIds: [],
});
const broadcast = vi.fn();
const svc = makeService({ get, remove, broadcast } as Partial<AccommodationsService>);
new AccommodationsController(svc).remove(user, '5', '9', 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 4 }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 5 }, 'sock');
});
});
});
@@ -337,9 +337,60 @@ describe('deleteAccommodation', () => {
const result = svc.deleteAccommodation(accom.id);
expect(result).toEqual({ linkedReservationId: reservation.id, deletedBudgetItemId: budgetItemId });
expect(result).toEqual({
linkedReservationId: reservation.id,
deletedBudgetItemId: budgetItemId,
linkedReservationIds: [reservation.id],
deletedBudgetItemIds: [budgetItemId],
});
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(budgetItemId)).toBeUndefined();
});
it('ACC-010 — a second booking pointed at the same stay goes too, instead of being orphaned', () => {
// reservations.accommodation_id carries no foreign key and no unique constraint,
// and the booking form lets a hotel booking pick an existing stay. Deleting only
// the first left the second behind, referencing a stay that no longer exists.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = svc.createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
}) as any;
const first = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
const second = testDb.prepare(
'INSERT INTO reservations (trip_id, type, title, accommodation_id) VALUES (?, ?, ?, ?)'
).run(trip.id, 'hotel', 'Second booking', accom.id).lastInsertRowid as number;
const result = svc.deleteAccommodation(accom.id);
expect(result.linkedReservationIds).toEqual([first.id, second]);
expect(result.linkedReservationId).toBe(first.id);
expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 0 });
});
it('ACC-011 — updating the stay syncs the times onto every linked booking', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = svc.createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
}) as any;
testDb.prepare(
'INSERT INTO reservations (trip_id, type, title, accommodation_id) VALUES (?, ?, ?, ?)'
).run(trip.id, 'hotel', 'Second booking', accom.id);
const existing = testDb.prepare('SELECT * FROM day_accommodations WHERE id = ?').get(accom.id) as any;
svc.updateAccommodation(accom.id, existing, { check_in: '15:00', check_out: '11:00' });
const rows = testDb.prepare('SELECT metadata FROM reservations WHERE accommodation_id = ?').all(accom.id) as Array<{ metadata: string | null }>;
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(JSON.parse(row.metadata || '{}')).toMatchObject({ check_in_time: '15:00', check_out_time: '11:00' });
}
});
});
describe('listAccommodations', () => {
@@ -446,7 +497,12 @@ describe('route-facing delegators', () => {
const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
// The controller broadcasts reservation:deleted off this return value.
expect(svc.remove(String(accom.id))).toEqual({ linkedReservationId: reservation.id, deletedBudgetItemId: null });
expect(svc.remove(String(accom.id))).toEqual({
linkedReservationId: reservation.id,
deletedBudgetItemId: null,
linkedReservationIds: [reservation.id],
deletedBudgetItemIds: [],
});
expect(svc.get(accom.id, trip.id)).toBeUndefined();
});
});
@@ -4,6 +4,7 @@ import type { Response } from 'express';
import { AtlasController } from '../../../src/nest/atlas/atlas.controller';
import { TravelStatsController } from '../../../src/nest/atlas/travel-stats.controller';
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
import { BucketItemExistsError } from '../../../src/nest/atlas/atlas.service';
import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
import type { User } from '../../../src/types';
@@ -139,6 +140,26 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', ()
.toEqual({ item: { id: 1, name: 'Kyoto' } });
});
it('409 when the service refuses a duplicate create (#1898)', () => {
const createBucketItem = vi.fn(() => { throw new BucketItemExistsError(); });
return thrown(() => makeController({ createBucketItem }).createBucketItem(user, { name: 'Japan' })).then((r) =>
expect(r).toEqual({ status: 409, body: { error: 'Already on your bucket list' } }));
});
it('409 when the service refuses a duplicate update (#1898)', () => {
const updateBucketItem = vi.fn(() => { throw new BucketItemExistsError(); });
return thrown(() => makeController({ updateBucketItem }).updateBucketItem(user, '1', { target_date: '2027-05' })).then((r) =>
expect(r).toEqual({ status: 409, body: { error: 'Already on your bucket list' } }));
});
it('lets any other create/update failure through untouched', () => {
const boom = new Error('disk on fire');
const createBucketItem = vi.fn(() => { throw boom; });
expect(() => makeController({ createBucketItem }).createBucketItem(user, { name: 'Japan' })).toThrow(boom);
const updateBucketItem = vi.fn(() => { throw boom; });
expect(() => makeController({ updateBucketItem }).updateBucketItem(user, '1', { name: 'Japan' })).toThrow(boom);
});
it('404 on delete of a missing item', () => {
const deleteBucketItem = vi.fn().mockReturnValue(false);
return thrown(() => makeController({ deleteBucketItem }).deleteBucketItem(user, '9')).then((r) =>
+118 -1
View File
@@ -42,7 +42,7 @@ import { createUser, createTrip, createReservation } from '../../helpers/factori
import { getCountryFromCoords, getCountryFromAddress, isPointInCountryBox, reverseGeocodeCountry, getRegionGeo, getCountryGeo } from '../../../src/nest/atlas/atlas-geo';
import { cacheKeyFor, getCached, setCached } from '../../../src/nest/geo/nominatim.client';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
import { AtlasService, BucketItemExistsError } from '../../../src/nest/atlas/atlas.service';
// Direct construction over the shared test connection — no TestingModule (repo
// convention for DI-native service unit tests).
@@ -1172,6 +1172,123 @@ describe('atlas quirk fixes', () => {
});
});
// ── #1898: one bucket-list entry per wish ────────────────────────────────────
//
// Clicking "add to bucket list" twice used to append a second identical row. The
// identity a row is checked against is (user, name, country, target date,
// coordinates) — a different target date is a different wish and stays allowed,
// which is exactly what the report asks for.
describe('bucket-list duplicates (#1898)', () => {
const countRows = (userId: number): number =>
(testDb.prepare('SELECT COUNT(*) AS n FROM bucket_list WHERE user_id = ?').get(userId) as { n: number }).n;
it('ATLAS-SVC-037: adding the same wish twice is refused and writes no second row', () => {
const { user } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP' });
expect(() => atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP' })).toThrow(BucketItemExistsError);
expect(countRows(user.id)).toBe(1);
});
it('ATLAS-SVC-038: the same place with another target date is a separate entry', () => {
const { user } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP' });
atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2027-05' });
atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2028-09' });
expect(countRows(user.id)).toBe(3);
// ...but each of those dates only once.
expect(() => atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2027-05' })).toThrow(
BucketItemExistsError,
);
});
it('ATLAS-SVC-039: the name matches case- and whitespace-insensitively', () => {
const { user } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Kyoto' });
expect(() => atlas.createBucketItem(user.id, { name: ' kyoto ' })).toThrow(BucketItemExistsError);
expect(countRows(user.id)).toBe(1);
});
it('ATLAS-SVC-040: an empty string and NULL are the same "not set"', () => {
const { user } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Lisbon', country_code: '', target_date: '' });
// The forms send '' where the map dialogs send null — both must land on the
// same identity, and the row itself is stored normalised.
const row = testDb.prepare('SELECT country_code, target_date FROM bucket_list WHERE user_id = ?').get(user.id);
expect(row).toEqual({ country_code: null, target_date: null });
expect(() => atlas.createBucketItem(user.id, { name: 'Lisbon', country_code: null, target_date: null })).toThrow(
BucketItemExistsError,
);
});
it('ATLAS-SVC-041: the same name in another country, at other coordinates or for another user still goes through', () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Altstadt', country_code: 'DE', lat: 48.13, lng: 11.57 });
atlas.createBucketItem(user.id, { name: 'Altstadt', country_code: 'AT', lat: 48.13, lng: 11.57 });
atlas.createBucketItem(user.id, { name: 'Altstadt', country_code: 'DE', lat: 50.94, lng: 6.96 });
atlas.createBucketItem(other.id, { name: 'Altstadt', country_code: 'DE', lat: 48.13, lng: 11.57 });
expect(countRows(user.id)).toBe(3);
expect(countRows(other.id)).toBe(1);
});
it('ATLAS-SVC-042: a coordinate-less wish does not collide with the same name pinned to a place', () => {
const { user } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Kyoto', country_code: 'JP' });
atlas.createBucketItem(user.id, { name: 'Kyoto', country_code: 'JP', lat: 35.01, lng: 135.76 });
expect(countRows(user.id)).toBe(2);
});
it('ATLAS-SVC-043: an update may not move a row onto another wish', () => {
const { user } = createUser(testDb);
atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2027-05' });
const second = atlas.createBucketItem(user.id, { name: 'Japan', country_code: 'JP', target_date: '2028-09' }) as {
id: number;
target_date: string;
};
expect(() => atlas.updateBucketItem(user.id, second.id, { target_date: '2027-05' })).toThrow(BucketItemExistsError);
const row = testDb.prepare('SELECT target_date FROM bucket_list WHERE id = ?').get(second.id) as {
target_date: string;
};
expect(row.target_date).toBe('2028-09');
});
it('ATLAS-SVC-044: an update of a row against itself is not a collision', () => {
const { user } = createUser(testDb);
const item = atlas.createBucketItem(user.id, { name: 'Kyoto', country_code: 'JP', target_date: '2027-05' }) as {
id: number;
};
// Same values again, plus a notes-only edit: neither may trip the guard.
expect(atlas.updateBucketItem(user.id, item.id, { name: 'Kyoto', target_date: '2027-05' })).toBeTruthy();
const updated = atlas.updateBucketItem(user.id, item.id, { notes: 'temples' }) as { notes: string };
expect(updated.notes).toBe('temples');
});
it('ATLAS-SVC-045: a legacy duplicate already in the table stays readable and deletable', () => {
const { user } = createUser(testDb);
// Rows written before the guard existed are left alone on purpose — no
// migration deletes user data for this.
const insert = testDb.prepare('INSERT INTO bucket_list (user_id, name, country_code) VALUES (?, ?, ?)');
insert.run(user.id, 'Japan', 'JP');
const legacy = insert.run(user.id, 'Japan', 'JP');
expect(atlas.bucketList(user.id)).toHaveLength(2);
expect(atlas.deleteBucketItem(user.id, Number(legacy.lastInsertRowid))).toBe(true);
expect(countRows(user.id)).toBe(1);
});
});
// ── #1048: visited vs planned vs idea ────────────────────────────────────────
//
// Before this, every trip painted its countries as visited — a flight booked for
@@ -96,6 +96,13 @@ function makeSettlementRow(
};
}
/**
* Add money the way the ledger does — in whole cents. Summing the returned
* two-decimal figures with `+` reintroduces exactly the binary dust the fix is
* about, so an "adds up to zero" assertion has to count cents to mean anything.
*/
const centSum = (values: number[]) => values.reduce((a, v) => a + Math.round(v * 100), 0);
function setupDb(
items: BudgetItem[],
members: (BudgetItemMember & { budget_item_id: number })[],
@@ -193,8 +200,10 @@ describe('calculateSettlement', () => {
[makePayer(1, 1, 20, 'alice'), makePayer(1, 2, 20, 'bob'), makePayer(1, 3, 20, 'carol')],
);
const result = budget.calculateSettlement(1);
// Exactly zero, not "within a cent": a tolerance here is what let #1382's
// stranded cents through unnoticed.
for (const b of result.balances) {
expect(Math.abs(b.balance)).toBeLessThanOrEqual(0.01);
expect(b.balance).toBe(0);
}
expect(result.flows).toHaveLength(0);
});
@@ -385,9 +394,8 @@ describe('calculateSettlement', () => {
],
);
const result = budget.calculateSettlement(1);
const sum = result.balances.reduce((a, b) => a + b.balance, 0);
expect(sum).toBeCloseTo(0, 2);
expect(centSum(result.balances.map(b => b.balance))).toBe(0);
});
it('3 payers on one bill: an odd total still splits to the cent', () => {
@@ -399,9 +407,111 @@ describe('calculateSettlement', () => {
[makePayer(1, 1, 33.34, 'alice'), makePayer(1, 2, 33.34, 'bob'), makePayer(1, 3, 33.33, 'carol')],
);
const result = budget.calculateSettlement(1);
const sum = result.balances.reduce((a, b) => a + b.balance, 0);
expect(sum).toBeCloseTo(0, 2);
expect(centSum(result.balances.map(b => b.balance))).toBe(0);
});
});
// ── #1382: balances and flows have to tell the same story ────────────────────
// The ledger is netted in whole cents of the trip currency, so a balance is only
// ever a whole number of cents and every one of them can be settled. What used to
// be smoothed over with a 0.01 tolerance is now exact.
describe('calculateSettlement — cent-exact settle-up (#1382)', () => {
it('#1382 offers the last cent as a flow instead of stranding it', () => {
// 30.00 fronted by Alice, split three ways. Bob transfers 9.99 instead of his
// 10.00 and Carol pays exactly, so one cent is still open. The reporter's
// symptom was that the balances showed that cent while "Settle up" offered
// nothing to clear it: below 0.01 the old filter dropped the person entirely.
setupDb(
[makeItem(1, 30)],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
[makePayer(1, 1, 30, 'alice')],
[makeSettlementRow(1, 2, 1, 9.99), makeSettlementRow(2, 3, 1, 10)],
);
const result = budget.calculateSettlement(1);
const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance;
expect(balance(1)).toBe(0.01);
expect(balance(2)).toBe(-0.01);
expect(balance(3)).toBe(0);
expect(result.flows).toEqual([
expect.objectContaining({ amount: 0.01, from: expect.objectContaining({ user_id: 2 }), to: expect.objectContaining({ user_id: 1 }) }),
]);
});
it('#1382 booking every offered flow leaves all balances at exactly zero', () => {
// 10.00 three ways is 3.34/3.33/3.33 — the case where the old greedy walked
// away from the rounding remainder. Recording the flows it offers must square
// the trip up completely, or the leftover cent is there forever.
const items = [makeItem(1, 10)];
const members = [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')];
const payers = [makePayer(1, 1, 10, 'alice')];
setupDb(items, members, payers);
const first = budget.calculateSettlement(1);
expect(first.flows.length).toBeGreaterThan(0);
const booked = first.flows.map((f, i) => makeSettlementRow(i + 1, f.from.user_id, f.to.user_id, f.amount));
setupDb(items, members, payers, booked);
const after = budget.calculateSettlement(1);
expect(after.balances.map(b => b.balance)).toEqual([0, 0, 0]);
expect(after.flows).toEqual([]);
});
it('#1382 a foreign-currency expense nets to exactly zero, not to a sub-cent residual', () => {
// 100 USD frozen at 1.1 is 90.909090… EUR: no split of it lands on whole cents.
// The equal split divides the credited cents rather than the foreign total, so
// the debits cancel the credits exactly instead of leaving dust behind.
setupDb(
[{ ...makeItem(1, 100), currency: 'USD', exchange_rate: 1.1 } as BudgetItem],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
[makePayer(1, 1, 100, 'alice')],
);
const result = budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.1 } });
expect(centSum(result.balances.map(b => b.balance))).toBe(0);
expect(centSum(result.flows.map(f => f.amount)))
.toBe(Math.round(result.balances.find(b => b.user_id === 1)!.balance * 100));
});
it('#1382 a display currency of its own neither invents nor loses a cent', () => {
// Nothing here is foreign: an all-EUR trip, but the viewer's preferred currency
// is USD. Rounding each balance into that currency on its own used to leave the
// set adding up to a cent that no flow could ever clear, and the drift moved
// with the live rate — money appearing without a single expense being touched.
setupDb(
[makeItem(1, 100)],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
[makePayer(1, 1, 100, 'alice')],
);
for (const eurPerUsd of [0.855, 0.9312, 0.94]) {
const result = budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { USD: 1, EUR: eurPerUsd } });
expect(centSum(result.balances.map(b => b.balance))).toBe(0);
for (const b of result.balances) {
// What settle-up would move for this person has to be their balance exactly.
const moved = centSum(result.flows.filter(f => f.to.user_id === b.user_id).map(f => f.amount))
- centSum(result.flows.filter(f => f.from.user_id === b.user_id).map(f => f.amount));
expect(moved).toBe(Math.round(b.balance * 100));
}
}
});
it('#1382 an unpaid bill still reads as money owed', () => {
// Nobody is down as a payer, so there is nothing to divide between the payers —
// the recorded total stands in, and the expense keeps showing up as outstanding
// rather than quietly netting to zero.
setupDb(
[makeItem(1, 90)],
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
[],
);
const result = budget.calculateSettlement(1);
expect(result.balances.map(b => b.balance)).toEqual([-30, -30, -30]);
expect(result.flows).toEqual([]);
});
});
@@ -255,6 +255,51 @@ describe('calculateSettlement custom splits', () => {
});
});
describe('calculateSettlement squares up to the cent (#1382)', () => {
it('BUDGET-SVC-DB-026: recording the offered flows clears the trip to exactly zero', () => {
const { user: alice } = createUser(testDb, { username: 'alice' });
const { user: bob } = createUser(testDb, { username: 'bob' });
const { user: carol } = createUser(testDb, { username: 'carol' });
const trip = createTrip(testDb, alice.id, { title: 'Trip' });
const members = [{ user_id: alice.id }, { user_id: bob.id }, { user_id: carol.id }];
// Totals that never divide by three, so every expense leaves a remainder cent
// somewhere: 10.00, 100.01 and 0.01 (the smallest debt there is).
budget.createBudgetItem(trip.id, { name: 'Coffee', payers: [{ user_id: alice.id, amount: 10 }], members });
budget.createBudgetItem(trip.id, { name: 'Dinner', payers: [{ user_id: bob.id, amount: 100.01 }], members });
budget.createBudgetItem(trip.id, { name: 'Stamp', payers: [{ user_id: carol.id, amount: 0.01 }], members });
const before = budget.calculateSettlement(trip.id);
expect(before.flows.length).toBeGreaterThan(0);
for (const f of before.flows) {
budget.insertSettlement(trip.id, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount }, f.from.user_id);
}
// Nothing left over, and nothing left to offer — the settle-up list and the
// balances agree instead of the balances holding a cent nobody can pay.
const after = budget.calculateSettlement(trip.id);
expect(after.balances.map(b => b.balance)).toEqual([0, 0, 0]);
expect(after.flows).toEqual([]);
});
it('BUDGET-SVC-DB-027: the balances hold still while the live rate moves under them', async () => {
const { trip, me, danil, serega } = seedIssue1543Trip('RUB');
// Nothing about the trip changes between the two reads; only the rate cache
// turned over. That was enough to shuffle cents between people (#1382).
const first = budget.calculateSettlement(trip.id, { base: 'RUB', tripCurrency: 'RUB', rates: RATES.RUB });
const second = budget.calculateSettlement(trip.id, {
base: 'RUB', tripCurrency: 'RUB',
rates: { RUB: 1, USD: 0.013042 * 1.03, EUR: 0.011412 * 0.97 },
});
for (const uid of [me.id, danil.id, serega.id]) {
expect(second.balances.find(b => b.user_id === uid)!.balance)
.toBe(first.balances.find(b => b.user_id === uid)!.balance);
}
});
});
/** The exact trip from #1543: RUB base, three members, one expense booked in USD. */
function seedIssue1543Trip(tripCurrency: string) {
const { user: me } = createUser(testDb, { username: 'me' });
@@ -667,6 +667,71 @@ describe('exportICS', () => {
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000');
expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris');
});
it('CAL-032: an all-day booking with an end date spans the whole range', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Festival Trip' });
const reservation = createReservation(testDb, trip.id, { title: 'Music Festival', type: 'event' });
// A date-only start/end pair is what the booking import writes for an all-day
// multi-day event. Without a DTEND the RFC reads the event as a single day, so
// the booking collapsed onto its first day (#1869).
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2026-08-26', '2026-08-30', reservation.id);
const { ics } = svc.exportICS(trip.id);
// DTEND is exclusive, so the day after the last day of the booking.
expect(ics).toContain('DTSTART;VALUE=DATE:20260826\r\nDTEND;VALUE=DATE:20260831');
});
it('CAL-033: a timed end time on an all-day booking still yields a VALUE=DATE DTEND', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Mixed Precision' });
const reservation = createReservation(testDb, trip.id, { title: 'Cottage', type: 'other' });
// Mixing DTSTART;VALUE=DATE with a date-time DTEND violates RFC 5545 §3.8.2.2
// (both ends must share a value type), so only the date part may be used.
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2026-08-26', '2026-08-30T11:00', reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260826\r\nDTEND;VALUE=DATE:20260831');
expect(ics).not.toContain('DTEND:20260830T110000');
});
it('CAL-034: an end date before the start date emits no DTEND', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Backwards' });
const reservation = createReservation(testDb, trip.id, { title: 'Swapped Dates', type: 'event' });
// Clients either drop an event whose DTEND precedes its DTSTART or render it
// with zero duration; leaving the end out keeps the single-day fallback.
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2026-08-26', '2026-08-20', reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260826');
expect(ics).not.toContain('DTEND');
});
it('CAL-035: a clock-only end time on an all-day booking emits no DTEND', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Half Timed' });
const reservation = createReservation(testDb, trip.id, { title: 'Workshop', type: 'other' });
// Relative "Day N" trips store a bare clock time; there is no date to anchor it
// to, so it cannot become the end of an all-day range.
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2026-08-26', '11:00', reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260826');
expect(ics).not.toContain('DTEND');
});
});
// ── Accommodations in the feed (#1586) ───────────────────────────────────────
@@ -804,6 +869,32 @@ describe('accommodations', () => {
expect(ics).not.toContain('Check-out');
expect(ics).not.toContain('DTSTART;VALUE=DATE:');
});
it('CAL-036: two bookings on one stay emit the check-in/check-out markers once', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
const { stayId } = createStay(trip.id, {
start: '2026-07-07', end: '2026-07-12', check_in: '15:00', check_out: '11:00',
});
// Nothing stops a second booking from pointing at the same accommodation. The
// markers are keyed by the stay, so joining the reservations in fanned the stay
// out into two VEVENTs carrying the same UID, and clients then show whichever
// one they saw last (#1869).
testDb.prepare(`
INSERT INTO reservations (trip_id, title, reservation_time, status, type, accommodation_id)
VALUES (?, 'Bellevue second room', NULL, 'confirmed', 'hotel', ?)
`).run(trip.id, String(stayId));
const { ics } = svc.exportICS(trip.id);
expect(ics.match(/UID:trek-checkin-\d+@trek/g)).toHaveLength(1);
expect(ics.match(/UID:trek-checkout-\d+@trek/g)).toHaveLength(1);
// The lowest booking id names the markers, so the title cannot flip between
// exports. The second booking keeps its own event (its UID is its own).
expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue\r\n');
expect(ics).not.toContain('Check-in: Bellevue second room');
expect(ics).toContain('SUMMARY:Bellevue second room');
});
});
describe('folded quirk branches', () => {
@@ -245,6 +245,25 @@ describe('status + updatePlace move', () => {
const p = svc.savePlace(owner.id, { collection_id: a.id, name: 'X' }).place!;
expect(() => svc.updatePlace(owner.id, p.id, { collection_id: b.id })).toThrow();
});
// #1870: the address column was missing from the UPDATE set, so a typo or a
// moved restaurant could only be fixed by deleting and re-adding the place.
it('COLLECTIONS-SVC-019: updatePlace corrects the address and clears it with null', () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Rome' });
const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Trattoria', address: 'Via Vechia 1' }).place!;
expect(svc.updatePlace(u.id, p.id, { address: 'Via Nuova 1' }).address).toBe('Via Nuova 1');
expect(svc.updatePlace(u.id, p.id, { address: null }).address).toBeNull();
});
it('COLLECTIONS-SVC-019b: an update without an address leaves the stored one alone', () => {
const u = createUser(testDb).user;
const col = svc.createCollection(u.id, { name: 'Rome' });
const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Trattoria', address: 'Via Vechia 1' }).place!;
expect(svc.updatePlace(u.id, p.id, { name: 'Trattoria da Enzo' }).address).toBe('Via Vechia 1');
});
});
// ── copy to trip ─────────────────────────────────────────────────────────────
@@ -1,8 +1,23 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// prepare() has to answer per statement now: the resolver reads the addon row
// AND the caller's role, and a single shared stub would hand the role lookup the
// addon row (silently green tests for the #1772 gate).
const { dbMock } = vi.hoisted(() => {
const stmt = { get: vi.fn() };
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
const addonStmt = { get: vi.fn() };
const roleStmt = { get: vi.fn() };
const other = { get: vi.fn(), all: vi.fn(), run: vi.fn() };
return {
dbMock: {
prepare: vi.fn((sql: string) => {
if (sql.includes('FROM addons')) return addonStmt;
if (sql.includes('FROM users')) return roleStmt;
return other;
}),
_addon: addonStmt,
_role: roleStmt,
},
};
});
vi.mock('../../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
@@ -17,21 +32,28 @@ import type { AddonsService } from '../../../../src/nest/addons/addons.service';
// legacy-module path mock (same behaviors as before the DI move). The
// DatabaseService rides the same prepare/get mock the module-level db used.
const getUserSettings = vi.fn(() => ({}) as Record<string, unknown>);
const getAdminUserDefaults = vi.fn(() => ({}) as Record<string, unknown>);
const getDecryptedUserSetting = vi.fn(() => null as string | null);
const settingsStub = { getUserSettings, getDecryptedUserSetting } as unknown as SettingsService;
const settingsStub = { getUserSettings, getAdminUserDefaults, getDecryptedUserSetting } as unknown as SettingsService;
const addonsStub = { isAddonEnabled } as unknown as AddonsService;
const resolver = new LlmConfigResolver(settingsStub, new DatabaseService(dbMock as never), addonsStub);
function setInstanceConfig(config: unknown) {
dbMock._stmt.get.mockReturnValue(config === undefined ? undefined : { config: JSON.stringify(config) });
dbMock._addon.get.mockReturnValue(config === undefined ? undefined : { config: JSON.stringify(config) });
}
function setRole(role: 'user' | 'admin' | undefined) {
dbMock._role.get.mockReturnValue(role === undefined ? undefined : { role });
}
beforeEach(() => {
vi.clearAllMocks();
isAddonEnabled.mockReturnValue(true);
setInstanceConfig(undefined);
setRole('user');
getUserSettings.mockReturnValue({});
getAdminUserDefaults.mockReturnValue({});
getDecryptedUserSetting.mockReturnValue(null);
});
@@ -52,14 +74,20 @@ describe('resolveLlmConfig', () => {
});
});
it('instance config with a base URL still wins for a plain user (#1772 does not touch it)', () => {
setInstanceConfig({ provider: 'local', model: 'nuextract', baseUrl: 'http://ollama:11434' });
setRole('user');
expect(resolver.resolve(7)).toMatchObject({ provider: 'local', baseUrl: 'http://ollama:11434' });
});
it('falls back to per-user config when instance config is incomplete', () => {
setInstanceConfig({ provider: 'anthropic' }); // no model → not usable
getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://x/v1', llm_multimodal: true });
getUserSettings.mockReturnValue({ llm_provider: 'anthropic', llm_model: 'claude-sonnet', llm_multimodal: true });
getDecryptedUserSetting.mockReturnValue('user-key');
expect(resolver.resolve(7)).toEqual({
provider: 'local',
model: 'nuextract',
baseUrl: 'http://x/v1',
provider: 'anthropic',
model: 'claude-sonnet',
baseUrl: undefined,
apiKey: 'user-key',
multimodal: true,
});
@@ -70,4 +98,41 @@ describe('resolveLlmConfig', () => {
getUserSettings.mockReturnValue({ llm_provider: 'openai' }); // no model
expect(resolver.resolve(1)).toBeNull();
});
// #1772: the endpoint is instance configuration, so it may only come from an
// admin-controlled source, whoever is asking.
it('#1772: picking local personally gets no config at all (no silent reroute)', () => {
getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
expect(resolver.resolve(7)).toBeNull();
});
it('#1772: a personal OpenAI config survives but loses its own base URL', () => {
getUserSettings.mockReturnValue({ llm_provider: 'openai', llm_model: 'gpt-4o-mini', llm_base_url: 'http://192.168.1.5:11434' });
getDecryptedUserSetting.mockReturnValue('sk-user');
expect(resolver.resolve(7)).toEqual({
provider: 'openai',
model: 'gpt-4o-mini',
baseUrl: undefined,
apiKey: 'sk-user',
multimodal: false,
});
});
it('#1772: an admin-set instance default endpoint applies to everyone', () => {
// getUserSettings merges the admin defaults in; getAdminUserDefaults is the
// admin-controlled layer the endpoint is allowed to come from.
getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://ollama.internal:11434' });
getAdminUserDefaults.mockReturnValue({ llm_provider: 'local', llm_base_url: 'http://ollama.internal:11434' });
expect(resolver.resolve(7)).toMatchObject({ provider: 'local', baseUrl: 'http://ollama.internal:11434' });
});
it('#1772: the caller\'s role does not change the answer, and no role is looked up', () => {
// An instance has one endpoint. An admin who parked one in their own row is
// in exactly the same position as anyone else, and the resolver no longer
// reads the users table at all.
setRole('admin');
getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
expect(resolver.resolve(7)).toBeNull();
expect(dbMock._role.get).not.toHaveBeenCalled();
});
});

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