From 85e820dee530b857f2f72a3df30c3fdf78d9349b Mon Sep 17 00:00:00 2001 From: Konstantinos Thermos Date: Wed, 12 Aug 2026 17:31:30 +0300 Subject: [PATCH 01/20] fix(atlas): stop the panel clipping the top of the bucket search results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bucket-list place search opened its result list upward with position:absolute inside the desktop Atlas panel, which is overflow-hidden for its rounded corners. The panel leaves ~125px above the input, so the top of the 160px list was painted outside the clip box — the first result was invisible and unreachable, since that is the container's own top and not a scroll offset. Portal the list to the body and position it fixed off the input row, flipping up or down by available room and capping its height to that room. This matches CustomSelect, whose month/year dropdowns already escape the same panel this way. Fixes #1899 --- client/src/pages/AtlasPage.test.tsx | 41 ++++++++++++++++++++++++++--- client/src/pages/AtlasPage.tsx | 33 +++++++++++++++++++---- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/client/src/pages/AtlasPage.test.tsx b/client/src/pages/AtlasPage.test.tsx index b035819d6..dcd6c5bf8 100644 --- a/client/src/pages/AtlasPage.test.tsx +++ b/client/src/pages/AtlasPage.test.tsx @@ -922,6 +922,41 @@ describe('AtlasPage', () => { }); }); + describe('FE-PAGE-ATLAS-051: bucket search results escape the panel that clips them', () => { + it('renders the result list outside the overflow-hidden desktop panel (#1899)', async () => { + server.use( + http.post('/api/maps/search', () => HttpResponse.json({ + places: [ + { name: 'Amsterdam', address: 'Amsterdam, North Holland, Netherlands', lat: 52.37, lng: 4.89 }, + { name: 'New Amsterdam Island', address: 'French Southern and Antarctic Lands, France', lat: -37.8, lng: 77.5 }, + { name: 'City of Amsterdam', address: 'North Holland, Netherlands', lat: 52.35, lng: 4.9 }, + ], + })), + ); + + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByText('Bucket List').length).toBeGreaterThan(0)); + await user.click(screen.getAllByText('Bucket List')[0]); + await waitFor(() => expect(screen.getAllByRole('button', { name: /add place/i }).length).toBeGreaterThan(0)); + await user.click(screen.getAllByRole('button', { name: /add place/i })[0]); + + const nameInput = await screen.findByPlaceholderText(/name \(country, city, place\.\.\.\)/i); + await user.type(nameInput, 'Amsterdam{Enter}'); + + // The first hit must be present — it is the one the panel used to swallow. + const firstHit = await screen.findByText('Amsterdam, North Holland, Netherlands'); + const list = firstHit.closest('button')!.parentElement as HTMLElement; + + // Portalled straight onto the body, so no ancestor's overflow can clip it. + expect(list.style.position).toBe('fixed'); + expect(list.parentElement).toBe(document.body); + expect(list.closest('.overflow-hidden')).toBeNull(); + expect(screen.getByText('City of Amsterdam')).toBeInTheDocument(); + }); + }); + describe('FE-PAGE-ATLAS-033: GeoJSON with unvisited country covers onEachFeature else branch', () => { it('loads map with visited FR and unvisited DE, covering both onEachFeature branches', async () => { const geoJsonFRandDE = { @@ -1291,7 +1326,7 @@ describe('AtlasPage', () => { () => { const els = screen.queryAllByText('Tokyo'); // Filter to those that are inside the search results dropdown (not the input itself) - const resultEl = els.find((el) => el.tagName !== 'INPUT' && el.closest('div[style*="position: absolute"]')); + const resultEl = els.find((el) => el.tagName !== 'INPUT' && el.closest('div[style*="position: fixed"]')); if (!resultEl) throw new Error('Tokyo result not found in dropdown'); return resultEl; }, @@ -1571,12 +1606,12 @@ describe('AtlasPage', () => { await user.type(nameInput, 'Paris'); fireEvent.keyDown(nameInput, { key: 'Enter' }); - // Wait for Paris result in the dropdown (absolute-positioned list) + // Wait for Paris result in the dropdown (portalled, fixed-position list) const parisBtn = await waitFor( () => { const btns = Array.from(document.querySelectorAll('button')); const btn = btns.find( - (b) => b.textContent?.includes('Paris') && b.closest('[style*="position: absolute"]'), + (b) => b.textContent?.includes('Paris') && b.closest('[style*="position: fixed"]'), ); if (!btn) throw new Error('Paris dropdown result not found'); return btn; diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index a0c1cc4b3..a1cfb6b80 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { useTranslation } from '../i18n' import Navbar from '../components/Layout/Navbar' import apiClient from '../api/client' @@ -470,6 +471,7 @@ interface SidebarContentProps { function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement { const { language } = useTranslation() const statsContentRef = useRef(null) + const bucketSearchRowRef = useRef(null) const [statsWidth, setStatsWidth] = useState(undefined) useEffect(() => { const el = statsContentRef.current @@ -556,8 +558,8 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail {showBucketAdd ? (
{/* Search or manual name */} -
-
+
+
{ const v = e.target.value; if (bucketForm.name) setBucketForm({ ...bucketForm, name: v }); else setBucketSearch(v) }} onKeyDown={e => { if (e.key === 'Enter' && !bucketForm.name) onSearchBucket(); else if (e.key === 'Enter') onAddBucket(); if (e.key === 'Escape') setShowBucketAdd(false) }} @@ -581,15 +583,36 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail )}
- {bucketSearchResults.length > 0 && ( -
+ {/* Portalled to the body: the panel that hosts this form clips its + overflow, which used to swallow the top of the list (#1899). */} + {bucketSearchResults.length > 0 && createPortal( +
{ + const rect = bucketSearchRowRef.current?.getBoundingClientRect() + if (!rect) return { left: 0, top: 0, width: 240, maxHeight: 160 } + const above = rect.top - 12 + const below = window.innerHeight - rect.bottom - 12 + const openUp = above >= below + return { + left: rect.left, + width: rect.width, + // Never taller than the room on that side, so the list scrolls + // instead of running off the edge of the viewport. + maxHeight: Math.min(160, openUp ? above : below), + ...(openUp ? { bottom: window.innerHeight - rect.top + 4 } : { top: rect.bottom + 4 }), + } + })(), + zIndex: 99999, borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', overflowY: 'auto', + }}> {bucketSearchResults.slice(0, 6).map((r, i) => ( ))} -
+
, + document.body )}
{/* Selected place indicator */} From df3edac7b9d5d893467f2372a6eaeac97840a4fe Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:43:22 +0200 Subject: [PATCH 02/20] =?UTF-8?q?feat:=20six=20community=20requests=20?= =?UTF-8?q?=E2=80=94=20import=20dedup,=20accommodations=20in=20the=20feed,?= =?UTF-8?q?=20OAuth=20refresh=20race,=20visited-from-trip,=20journal=20jum?= =?UTF-8?q?ps,=20phone=20packing=20rows=20(#1915)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(places): match imports on the provider id so renamed places are not duplicated Re-importing a Google Maps list duplicated every place the user had renamed. buildDedupSet only ever collected names and, for unnamed places, coordinates, and isPlaceDuplicate returns on the name before it reaches the coordinate fallback, so a renamed place was findable by neither. The ids were stored all along, and even backfilled onto matched places, they were simply never read. Match on google_place_id / google_ftid / osm_id first, keeping name and coordinates in their old roles below it. Widening the coordinate check instead would have merged the restaurant and the bar in the same building. The collection copy carried a hand-mirrored port of the same predicate; it now uses the shared helpers and gets the fix with them. Closes #1550 * feat(calendar): put accommodations into the subscribed feed as all-day stays A hotel keeps its dates on the linked stay, not on the reservation: the booking form writes reservation_time = NULL for type 'hotel', and day_accommodations carries start day, end day and the check-in/out clock. The feed only ever read the reservation, so a stay showed up once on the arrival day at best, and not at all when it came from the booking form. Join the stay in and emit the reservation as an all-day range over every night, which also keeps it correct when days are reordered later — the dates now come from the day rows rather than from the copy stamped at creation time. Check-in and check-out become their own timed events when the stay records a clock, because an all-day event cannot carry the time you actually need reminding of. The check-in window, when set, becomes that event's DTEND. Closes #1586 * fix(oauth): treat a concurrent refresh as a race, not as a stolen token Refresh rotation is a race by construction. Several MCP clients share one refresh token, both post it within the same second, and the loser was read as a replay: chain revoked, MCP sessions torn down, browser window asking to log in again. That is the daily re-authentication people were seeing, not a short lifetime — access tokens last an hour and refresh tokens roll for thirty days. A revoked refresh token now only counts as a replay once it is outside a 30-second grace window, and only when the rotation that revoked it left a successor still alive. Inside the window the caller gets its own pair branched off the same parent, so both clients walk away with a working token. Explicit revocation leaves no live successor and a chain killed by a real replay has none either, so neither can slip through the window. Closes #1007 * feat(collections): mark a place visited from the trip it belongs to A place saved in several lists had to be opened in each of them to say you had been there, which is the wrong way round: you find out you have been somewhere while looking at the trip, not while browsing the library. The save picker now shows each list's own status and cycles it in place, with one action for "visited, wherever this is saved". The planner's selection bar and the mobile bulk toolbar carry the same thing for a whole selection — select all, one tap, done. Matching happens on the server with the signals findMembership already uses plus the source link a place saved from this trip carries, so a copy renamed inside a list is still found. Lists you may only read are skipped rather than refused; a shared list you cannot edit should not stop you marking your own. Closes #1469 * feat(journey): jump to the top or back to the last entry in a long journal Everything that adds to a journal sits at the top — the entry button, the gallery upload — and where you left off sits at the bottom, so adding one photo to a long journey meant scrolling the whole way twice. Two buttons float over the middle of the feed, clear of the Add Entry column on one side and the reorder arrows on the other, each appearing only when there is more than a screenful to travel in that direction. They live in a zero-height sticky box so they take no layout space and never push the timeline around. While in the header, the status pill also picks up the height of the round buttons beside it. Closes #1088 * fix(packing): give the item name the row back on phones An item name had roughly a third of the row to itself: a spelled-out sharing sentence, an empty weight column and two round action buttons took the rest, so anything longer than a word was cut off. Sharing is now the icon it always was next to a label rather than a sentence in the layout, an unset weight prints nothing instead of a dash, and edit/delete sit behind one overflow button that opens the same inline menu the bag picker already uses. The to-do list needed none of this — it wraps its chips onto a second line already. Closes #1525 * docs(wiki): cover the six community changes - Calendar feeds: accommodations as an all-day range plus check-in/check-out - Collections: setting the visited status from a trip, and how places are matched - Journey: the jump buttons on a long journal - Packing: what the phone row keeps and what moved behind the overflow button - Places: re-importing a list no longer duplicates renamed places - MCP setup: the rotation race no longer ends an OAuth session * test(collections): assert the stored status instead of a private method tests/ sits outside the build tsconfig, so reaching into getPlaceById only showed up in the dedicated typecheck step. Reading the row is the better assertion anyway — it pins what was persisted, not what a getter returns. --- client/src/api/collections.ts | 5 + .../MSaveToCollectionSheet.test.tsx | 4 +- .../Collections/MSaveToCollectionSheet.tsx | 88 ++++++++- .../SaveToCollectionModal.test.tsx | 65 ++++++- .../Collections/SaveToCollectionModal.tsx | 77 +++++++- .../Planner/PlacesSidebarSelectionBar.tsx | 23 ++- .../components/Planner/usePlacesSidebar.ts | 26 +++ .../screens/trip/places/MPlacesBrowser.tsx | 32 +++- .../screens/trip/tabs/MPackingListTab.tsx | 72 ++++++-- client/src/pages/JourneyDetailPage.tsx | 40 +++- .../pages/JourneyDetailPage.wiring.test.tsx | 35 ++++ .../journeyDetail/useJourneyDetail.test.tsx | 50 ++++- .../pages/journeyDetail/useJourneyDetail.ts | 53 ++++++ .../unit/mobile/trip/MPackingListTab.test.tsx | 64 ++++++- server/src/nest/calendar/calendar.service.ts | 72 +++++++- .../collections/collections.controller.ts | 14 ++ .../src/nest/collections/collections.dto.ts | 4 + .../nest/collections/collections.service.ts | 173 ++++++++++++++---- server/src/nest/oauth/oauth.helpers.ts | 23 +++ server/src/nest/oauth/oauth.service.ts | 39 ++++ server/src/nest/places/places.helpers.ts | 47 ++++- server/src/nest/places/places.service.ts | 34 +++- server/tests/e2e/collections.e2e.test.ts | 35 ++++ server/tests/integration/oauth.test.ts | 53 ++++++ .../tests/unit/nest/calendar.service.test.ts | 137 ++++++++++++++ .../unit/nest/collections.service.test.ts | 106 +++++++++++ server/tests/unit/nest/oauth.service.test.ts | 98 +++++++++- server/tests/unit/nest/places.helpers.test.ts | 42 ++++- server/tests/unit/nest/places.service.test.ts | 52 ++++++ shared/src/collection/collection.schema.ts | 35 +++- shared/src/i18n/ar/collection.ts | 7 + shared/src/i18n/ar/journey.ts | 2 + shared/src/i18n/br/collection.ts | 7 + shared/src/i18n/br/journey.ts | 2 + shared/src/i18n/ca/collection.ts | 7 + shared/src/i18n/ca/journey.ts | 2 + shared/src/i18n/cs/collection.ts | 7 + shared/src/i18n/cs/journey.ts | 2 + shared/src/i18n/de/collection.ts | 7 + shared/src/i18n/de/journey.ts | 2 + shared/src/i18n/en/collection.ts | 7 + shared/src/i18n/en/journey.ts | 2 + shared/src/i18n/es/collection.ts | 7 + shared/src/i18n/es/journey.ts | 2 + shared/src/i18n/fr/collection.ts | 7 + shared/src/i18n/fr/journey.ts | 2 + shared/src/i18n/gr/collection.ts | 7 + shared/src/i18n/gr/journey.ts | 2 + shared/src/i18n/hu/collection.ts | 7 + shared/src/i18n/hu/journey.ts | 2 + shared/src/i18n/id/collection.ts | 7 + shared/src/i18n/id/journey.ts | 2 + shared/src/i18n/it/collection.ts | 7 + shared/src/i18n/it/journey.ts | 2 + shared/src/i18n/ja/collection.ts | 7 + shared/src/i18n/ja/journey.ts | 2 + shared/src/i18n/ko/collection.ts | 7 + shared/src/i18n/ko/journey.ts | 2 + shared/src/i18n/nl/collection.ts | 7 + shared/src/i18n/nl/journey.ts | 2 + shared/src/i18n/pl/collection.ts | 7 + shared/src/i18n/pl/journey.ts | 2 + shared/src/i18n/ru/collection.ts | 7 + shared/src/i18n/ru/journey.ts | 2 + shared/src/i18n/sv/collection.ts | 7 + shared/src/i18n/sv/journey.ts | 2 + shared/src/i18n/tr/collection.ts | 7 + shared/src/i18n/tr/journey.ts | 2 + shared/src/i18n/uk/collection.ts | 7 + shared/src/i18n/uk/journey.ts | 2 + shared/src/i18n/vi/collection.ts | 7 + shared/src/i18n/vi/journey.ts | 2 + shared/src/i18n/zh-TW/collection.ts | 7 + shared/src/i18n/zh-TW/journey.ts | 2 + shared/src/i18n/zh/collection.ts | 7 + shared/src/i18n/zh/journey.ts | 2 + wiki/Calendar-Feeds.md | 2 + wiki/Collections.md | 9 + wiki/Journey-Journal.md | 4 + wiki/MCP-Setup.md | 2 +- wiki/Packing-Lists.md | 2 + wiki/Places-and-Search.md | 2 + 82 files changed, 1721 insertions(+), 105 deletions(-) diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index ab6b15dec..be4b4c085 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -17,6 +17,7 @@ import type { CollectionInviteActionRequest, CollectionInviteCancelRequest, CollectionStatus, + CollectionSetStatusManyResponse, Collection, CollectionPlace, CollectionLabel, @@ -77,6 +78,10 @@ export const collectionsApi = { postMultipart(`${base}/places/${pid}/image`, formData), setStatus: (pid: number, status: CollectionStatus): Promise => ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data), + setStatusMany: (ids: number[], status: CollectionStatus): Promise => + ax.post(`${base}/places/status-many`, { ids, status }).then((r: AxiosResponse) => r.data), + setStatusFromTrip: (tripId: number, placeIds: number[], status: CollectionStatus): Promise => + ax.post(`${base}/places/status-from-trip`, { trip_id: tripId, place_ids: placeIds, status }).then((r: AxiosResponse) => r.data), ratePlace: (pid: number, rating: number | null): Promise => rating === null ? ax.delete(`${base}/places/${pid}/rating`).then((r: AxiosResponse) => r.data) diff --git a/client/src/components/Collections/MSaveToCollectionSheet.test.tsx b/client/src/components/Collections/MSaveToCollectionSheet.test.tsx index 19b2940d4..bd4279f01 100644 --- a/client/src/components/Collections/MSaveToCollectionSheet.test.tsx +++ b/client/src/components/Collections/MSaveToCollectionSheet.test.tsx @@ -115,7 +115,7 @@ describe('MSaveToCollectionSheet', () => { it('FE-COMP-MSAVESHEET-006: lists already holding the place are marked saved', async () => { vi.spyOn(collectionsApi, 'membership').mockResolvedValue({ saved: true, - lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }], + lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }], }); openFor(); render(); @@ -167,7 +167,7 @@ describe('MSaveToCollectionSheet', () => { it('FE-COMP-MSAVESHEET-010: tapping a saved list removes that place instead', async () => { vi.spyOn(collectionsApi, 'membership').mockResolvedValue({ saved: true, - lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }], + lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }], }); const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({}); openFor(); diff --git a/client/src/components/Collections/MSaveToCollectionSheet.tsx b/client/src/components/Collections/MSaveToCollectionSheet.tsx index b5ddadf76..8afd008b0 100644 --- a/client/src/components/Collections/MSaveToCollectionSheet.tsx +++ b/client/src/components/Collections/MSaveToCollectionSheet.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState, useCallback } from 'react' import { useNavigate } from 'react-router' -import { Bookmark, BookmarkCheck, Check, Loader2, Plus, X } from 'lucide-react' +import { Bookmark, BookmarkCheck, Check, CheckCircle2, Loader2, Plus, X } from 'lucide-react' import MSheet from '../../mobile/components/MSheet' import MIconBtn from '../../mobile/components/MIconBtn' import { useToast } from '../shared/Toast' @@ -8,7 +8,8 @@ import { useTranslation } from '../../i18n' import { collectionsApi } from '../../api/collections' import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' import { getApiErrorMessage } from '../../utils/apiError' -import type { Collection, CollectionMembership } from '@trek/shared' +import { STATUS_META, nextStatus } from '../../pages/collections/collectionsModel' +import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared' /** * Mobile counterpart of SaveToCollectionModal — the same store-driven list @@ -71,12 +72,44 @@ export default function MSaveToCollectionSheet() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [target]) - const savedByCollection = new Map() - for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id) + const savedByCollection = new Map() + for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l) + + /** Lists holding this place that the viewer may edit and that are not visited yet. */ + const unvisited = (membership?.lists ?? []).filter(l => l.can_edit && l.status !== 'visited') + + const handleStatus = async (entry: CollectionMembership['lists'][number], next: CollectionStatus) => { + if (busyId != null) return + setBusyId(entry.collection_id) + try { + await collectionsApi.setStatus(entry.place_id, next) + await refreshMembership() + bumpVersion() + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))) + } finally { + setBusyId(null) + } + } + + const handleVisitedEverywhere = async () => { + if (busyId != null || unvisited.length === 0) return + setBusyId(-1) + try { + const { updated } = await collectionsApi.setStatusMany(unvisited.map(l => l.place_id), 'visited') + toast.success(t('collections.markedVisited', { count: updated })) + await refreshMembership() + bumpVersion() + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))) + } finally { + setBusyId(null) + } + } const handleToggle = async (list: Collection) => { if (busyId != null || !target) return - const savedPlaceId = savedByCollection.get(list.id) + const savedPlaceId = savedByCollection.get(list.id)?.place_id setBusyId(list.id) try { if (savedPlaceId != null) { @@ -156,8 +189,11 @@ export default function MSaveToCollectionSheet() {
) : ( lists.map(list => { - const saved = savedByCollection.has(list.id) + const entry = savedByCollection.get(list.id) + const saved = !!entry const busy = busyId === list.id + const statusMeta = entry ? STATUS_META[entry.status] : null + const StatusIcon = statusMeta?.icon return (
+ {unvisited.length > 0 && ( +
+ +
+ )} + {lists.length > 0 && (
+ )} +
{loading ? (
@@ -162,7 +211,8 @@ export default function SaveToCollectionModal(): React.ReactElement | null { ) : (
{lists.map(list => { - const saved = savedByCollection.has(list.id) + const entry = savedByCollection.get(list.id) + const saved = !!entry const busy = busyId === list.id return ( + + )} - - + )}
+ {menuOpen && editMode && canEdit && ( +
+ + +
+ )} + {bagPickerOpen && bagTrackingEnabled && (
+ )} + {!feedEdge.atBottom && ( + + )} +
+
+ )} +
diff --git a/client/src/pages/JourneyDetailPage.wiring.test.tsx b/client/src/pages/JourneyDetailPage.wiring.test.tsx index 863084086..601f9327d 100644 --- a/client/src/pages/JourneyDetailPage.wiring.test.tsx +++ b/client/src/pages/JourneyDetailPage.wiring.test.tsx @@ -105,6 +105,7 @@ function buildHook(over: Record = {}): Record mapRef: { current: null }, fullMapRef: { current: null }, activeLocationId: null, handleMarkerClick: vi.fn(), handleLocationClick: vi.fn(), mapEntries: [], sidebarMapItems: [], tripDates: new Set(), isMobile: false, + feedEdge: { atTop: true, atBottom: true }, scrollFeedTo: vi.fn(), loadJourney: vi.fn(), updateEntry: vi.fn(async () => {}), deleteEntry: vi.fn(async () => {}), reorderEntries: vi.fn(async () => {}), uploadPhotos: vi.fn(async () => ({ succeeded: [], failed: [] })), deletePhoto: vi.fn(async () => {}), @@ -458,4 +459,38 @@ describe('JourneyDetailPage wiring', () => { setup({ current: buildDetail({ cover_image: 'covers/j.jpg' }) }); expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).toBeInTheDocument(); }); + + // ── Jump buttons (#1088) ────────────────────────────────────────────────── + + it('FE-JRN-DETWIRE-029: a feed with nowhere to jump shows no buttons', () => { + setup({ feedEdge: { atTop: true, atBottom: true } }); + expect(screen.queryByLabelText('journey.detail.jumpToTop')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('journey.detail.jumpToLast')).not.toBeInTheDocument(); + }); + + it('FE-JRN-DETWIRE-030: each button appears only when that direction is available', () => { + const { unmount } = setup({ feedEdge: { atTop: false, atBottom: true } }); + expect(screen.getByLabelText('journey.detail.jumpToTop')).toBeInTheDocument(); + expect(screen.queryByLabelText('journey.detail.jumpToLast')).not.toBeInTheDocument(); + unmount(); + + setup({ feedEdge: { atTop: true, atBottom: false } }); + expect(screen.queryByLabelText('journey.detail.jumpToTop')).not.toBeInTheDocument(); + expect(screen.getByLabelText('journey.detail.jumpToLast')).toBeInTheDocument(); + }); + + it('FE-JRN-DETWIRE-031: the buttons scroll the feed to the matching end', () => { + const { hook } = setup({ feedEdge: { atTop: false, atBottom: false } }); + + fireEvent.click(screen.getByLabelText('journey.detail.jumpToTop')); + expect(hook.scrollFeedTo).toHaveBeenCalledWith('top'); + + fireEvent.click(screen.getByLabelText('journey.detail.jumpToLast')); + expect(hook.scrollFeedTo).toHaveBeenLastCalledWith('bottom'); + }); + + it('FE-JRN-DETWIRE-032: the phone layout has its own chrome and gets no jump buttons', () => { + setup({ isMobile: true, feedEdge: { atTop: false, atBottom: false } }); + expect(screen.queryByLabelText('journey.detail.jumpToTop')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/pages/journeyDetail/useJourneyDetail.test.tsx b/client/src/pages/journeyDetail/useJourneyDetail.test.tsx index 8ae8a5726..9f7c24d14 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.test.tsx +++ b/client/src/pages/journeyDetail/useJourneyDetail.test.tsx @@ -1,4 +1,4 @@ -// FE-JRN-DETHOOK-001 to FE-JRN-DETHOOK-022 +// FE-JRN-DETHOOK-001 to FE-JRN-DETHOOK-025 import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; import { useLocation } from 'react-router'; import { http, HttpResponse } from 'msw'; @@ -335,4 +335,52 @@ describe('useJourneyDetail', () => { await waitFor(() => expect(invalidateSize).toHaveBeenCalled()); expect(latest.view).toBe('gallery'); }); + + // ── Jump to top / to the last entry (#1088) ──────────────────────────────── + + /** jsdom has no layout, so hand the feed the numbers a real scrollport would report. */ + function stubFeedScroll(metrics: { scrollTop: number; scrollHeight: number; clientHeight: number }) { + const feed = screen.getByTestId('feed'); + for (const [prop, value] of Object.entries(metrics)) { + Object.defineProperty(feed, prop, { value, configurable: true }); + } + return feed; + } + + it('FE-JRN-DETHOOK-023: a feed short enough to read in one go offers no jump', async () => { + setup(); + await waitFor(() => expect(latest.current).not.toBeNull()); + const feed = stubFeedScroll({ scrollTop: 0, scrollHeight: 900, clientHeight: 800 }); + + fireEvent.scroll(feed); + await waitFor(() => expect(latest.feedEdge).toEqual({ atTop: true, atBottom: true })); + }); + + it('FE-JRN-DETHOOK-024: deep in a long journal both directions are offered', async () => { + setup(); + await waitFor(() => expect(latest.current).not.toBeNull()); + const feed = stubFeedScroll({ scrollTop: 3000, scrollHeight: 9000, clientHeight: 800 }); + + fireEvent.scroll(feed); + await waitFor(() => expect(latest.feedEdge).toEqual({ atTop: false, atBottom: false })); + + // Back at the bottom only the way up is left. + Object.defineProperty(feed, 'scrollTop', { value: 8200, configurable: true }); + fireEvent.scroll(feed); + await waitFor(() => expect(latest.feedEdge).toEqual({ atTop: false, atBottom: true })); + }); + + it('FE-JRN-DETHOOK-025: the jumps scroll the feed, not the window', async () => { + setup(); + await waitFor(() => expect(latest.current).not.toBeNull()); + const feed = stubFeedScroll({ scrollTop: 3000, scrollHeight: 9000, clientHeight: 800 }); + const scrollTo = vi.fn(); + Object.defineProperty(feed, 'scrollTo', { value: scrollTo, configurable: true }); + + latest.scrollFeedTo('top'); + expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + + latest.scrollFeedTo('bottom'); + expect(scrollTo).toHaveBeenLastCalledWith({ top: 9000, behavior: 'smooth' }); + }); }); diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index b83039806..2987d7c34 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -186,6 +186,58 @@ export function useJourneyDetail() { return () => scrollCleanupRef.current?.() }, [current?.entries, setupScrollSync]) + // ── Jump to top / to the last entry (#1088) ─────────────────────────────── + // A long journal is a long scroll: everything that adds to it — the entry + // button, the gallery upload — sits at the top, and where you were reading + // sits at the bottom. Two buttons beat two trips of the scroll wheel. + const [feedEdge, setFeedEdge] = useState<{ atTop: boolean; atBottom: boolean }>({ atTop: true, atBottom: true }) + + useEffect(() => { + const feed = feedRef.current + if (!feed) return + // 400px of travel before either button is worth offering; below that the + // scroll is short enough that a button is just clutter. + const THRESHOLD = 400 + let frame: number | null = null + let pending = false + const measure = () => { + pending = false + const el = feedRef.current + if (!el) return + const scrolled = el.scrollTop + const remaining = el.scrollHeight - el.clientHeight - scrolled + setFeedEdge(prev => { + const next = { atTop: scrolled <= THRESHOLD, atBottom: remaining <= THRESHOLD } + return prev.atTop === next.atTop && prev.atBottom === next.atBottom ? prev : next + }) + } + // The "already scheduled" flag is its own variable rather than the frame id: + // the id is only assigned once requestAnimationFrame returns, which is after + // the callback has already run whenever rAF fires synchronously. + const onScroll = () => { + if (pending) return + pending = true + frame = window.requestAnimationFrame(measure) + } + feed.addEventListener('scroll', onScroll, { passive: true }) + measure() + // Entries load in batches, so the scrollable height grows after mount. + const ro = new ResizeObserver(measure) + ro.observe(feed) + if (feed.firstElementChild) ro.observe(feed.firstElementChild) + return () => { + feed.removeEventListener('scroll', onScroll) + ro.disconnect() + if (frame != null) window.cancelAnimationFrame(frame) + } + }, [current?.id, view]) + + const scrollFeedTo = useCallback((edge: 'top' | 'bottom') => { + const el = feedRef.current + if (!el) return + el.scrollTo({ top: edge === 'top' ? 0 : el.scrollHeight, behavior: 'smooth' }) + }, []) + const handleMarkerClick = useCallback((entryId: string) => { const el = document.querySelector(`[data-entry-id="${entryId}"]`) if (!el) return @@ -303,6 +355,7 @@ export function useJourneyDetail() { hideSkeletons, setHideSkeletons, mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick, mapEntries, sidebarMapItems, tripDates, isMobile, tracks, + feedEdge, scrollFeedTo, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto, } } diff --git a/client/tests/unit/mobile/trip/MPackingListTab.test.tsx b/client/tests/unit/mobile/trip/MPackingListTab.test.tsx index 5a4ad5d21..5df8626aa 100644 --- a/client/tests/unit/mobile/trip/MPackingListTab.test.tsx +++ b/client/tests/unit/mobile/trip/MPackingListTab.test.tsx @@ -12,7 +12,7 @@ import { buildPlanner } from '../../../helpers/mobileTrip' import { resetAllStores, seedStore } from '../../../helpers/store' import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render' -// FE-MOB-PACKTAB-001 to FE-MOB-PACKTAB-046 +// FE-MOB-PACKTAB-001 to FE-MOB-PACKTAB-046 (plus 060-062) const ME = 7 const ANNA = { id: 11, username: 'anna', avatar: null, avatar_url: 'https://cdn.example/anna.png' } as unknown as TripMember @@ -648,16 +648,18 @@ describe('MPackingListTab', () => { await setup({ bagTracking: true }) enterEditMode() - // The row shows the unit weight, not quantity x weight. + // The row shows the unit weight, not quantity x weight; a row without one + // says nothing rather than spending width on a dash (#1525). expect(within(itemRow('Socks')).getByText('120 g')).toBeInTheDocument() - expect(within(itemRow('Shirt')).getByText('— g')).toBeInTheDocument() + expect(within(itemRow('Shirt')).queryByText('— g')).not.toBeInTheDocument() }) it('FE-MOB-PACKTAB-046: opens the item editor from the row', async () => { await setup() enterEditMode() - fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'common.edit' })) + fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'mobileTrip.more' })) + fireEvent.click(screen.getByRole('button', { name: 'common.edit' })) expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.editItem') }) @@ -666,7 +668,8 @@ describe('MPackingListTab', () => { const { planner } = await setup() enterEditMode() - await act(async () => { fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'common.delete' })) }) + fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'mobileTrip.more' })) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) }) expect(planner.tripActions.deletePackingItem).toHaveBeenCalledWith(3, 3) }) @@ -679,7 +682,8 @@ describe('MPackingListTab', () => { const { planner } = await setup({ items }) enterEditMode() - await act(async () => { fireEvent.click(within(itemRow('Rope')).getByRole('button', { name: 'common.delete' })) }) + fireEvent.click(within(itemRow('Rope')).getByRole('button', { name: 'mobileTrip.more' })) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) }) expect(planner.tripActions.togglePackingItem).toHaveBeenCalledWith(3, 8, false) expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 8, { @@ -693,7 +697,8 @@ describe('MPackingListTab', () => { asMock(planner.tripActions.deletePackingItem).mockRejectedValueOnce(new Error('x')) enterEditMode() - await act(async () => { fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'common.delete' })) }) + fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'mobileTrip.more' })) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) }) expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.deleteError') }) @@ -706,7 +711,7 @@ describe('MPackingListTab', () => { fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' })) - expect(screen.getByText('packing.takenCareOf:owner')).toBeInTheDocument() + expect(screen.getByLabelText('packing.takenCareOf:owner')).toBeInTheDocument() }) it('FE-MOB-PACKTAB-051: badges how many people my shared item covers', async () => { @@ -718,7 +723,7 @@ describe('MPackingListTab', () => { fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' })) - expect(screen.getByText('packing.sharedWithCount:2')).toBeInTheDocument() + expect(screen.getByLabelText('packing.sharedWithCount:2')).toBeInTheDocument() }) it('FE-MOB-PACKTAB-052: shows the owner avatar and the contributor count on a common item', async () => { @@ -886,8 +891,47 @@ describe('MPackingListTab', () => { await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) enterEditMode() - fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'common.edit' })) + fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'mobileTrip.more' })) + fireEvent.click(screen.getByRole('button', { name: 'common.edit' })) fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.close' })) await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) }) + + // ── Row width (#1525) ───────────────────────────────────────────────── + + it('FE-MOB-PACKTAB-063: the row actions stay behind one button until it is opened', async () => { + await setup() + enterEditMode() + + expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument() + fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'mobileTrip.more' })) + expect(screen.getByRole('button', { name: 'common.edit' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.delete' })).toBeInTheDocument() + + fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'mobileTrip.more' })) + expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument() + }) + + it('FE-MOB-PACKTAB-064: the menu and the bag picker never stand open together', async () => { + await setup({ bagTracking: true }) + enterEditMode() + + fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'mobileTrip.more' })) + expect(screen.getByRole('button', { name: 'common.edit' })).toBeInTheDocument() + + // Opening the bag picker on the same row closes the action menu. + fireEvent.click(within(itemRow('Socks')).getAllByRole('button', { name: 'packing.bags' })[0]) + expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /packing.noBag/ })).toBeInTheDocument() + }) + + it('FE-MOB-PACKTAB-065: the sharing state is an icon on the row, not a sentence', async () => { + const items = [packItem({ id: 1, name: 'Tent', is_private: 1, owner_id: 21, owner_username: 'owner' })] + await setup({ items }) + fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' })) + + // The wording is still reachable — it just does not eat the row any more. + expect(screen.queryByText('packing.takenCareOf:owner')).not.toBeInTheDocument() + expect(screen.getByLabelText('packing.takenCareOf:owner')).toBeInTheDocument() + }) }) diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index c2dc6c21d..74a824c47 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -147,11 +147,19 @@ export class CalendarService { const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any; if (!trip) throw new NotFoundError('Trip not found'); + // A hotel keeps its dates on the linked stay, not on the reservation: the + // booking form writes reservation_time = NULL for type 'hotel' and lets + // day_accommodations carry start day, end day and the check-in/out clock. + // Joining them here is what lets a stay span its whole range (#1586). const reservations = this.db .prepare( - `SELECT r.*, pl.lat AS place_lat, pl.lng AS place_lng + `SELECT r.*, pl.lat AS place_lat, pl.lng AS place_lng, + sd.date AS stay_start_date, ed.date AS stay_end_date FROM reservations r LEFT JOIN places pl ON r.place_id = pl.id + LEFT JOIN day_accommodations a ON r.accommodation_id = a.id + LEFT JOIN days sd ON a.start_day_id = sd.id + LEFT JOIN days ed ON a.end_day_id = ed.id WHERE r.trip_id = ?`, ) .all(tripId) as any[]; @@ -302,6 +310,18 @@ export class CalendarService { // both ends in the subscribed feed (#1453). Hotels/restaurants have no // endpoints and fall through to reservation_time. const buildReservationTimeLines = (r: any): string | null => { + // 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. + 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 + : r.stay_start_date; + return `DTSTART;VALUE=DATE:${fmtDate(r.stay_start_date)}\r\n` + + `DTEND;VALUE=DATE:${fmtDate(addDays(lastDay, 1))}\r\n`; + } + const eps = endpointsMap.get(r.id); const ordered = eps && eps.length > 0 ? [...eps].sort((a, b) => a.sequence - b.sequence) : null; const first = ordered?.[0]; @@ -389,6 +409,56 @@ export class CalendarService { events.push(ev); } + // Check-in and check-out as their own timed events, when the stay records the + // clock (#1586). They are separate from the all-day stay above on purpose: an + // 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. + 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 + 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[]; + + for (const stay of stays) { + const name = stay.reservation_title || stay.place_name || 'Accommodation'; + const zone = resolveTimeZone(stay.place_lat, stay.place_lng); + // No end day (the day row was removed) → check out on the arrival day. + const checkOutDate = isDate(stay.end_date) ? stay.end_date : stay.start_date; + + const marker = ( + kind: 'checkin' | 'checkout', + date: string, + time: string, + summary: string, + endTime?: string | null, + ) => { + const ref = `${date}T00:00`; + let ev = `BEGIN:VEVENT\r\nUID:${uid(stay.id, kind)}\r\nDTSTAMP:${now}\r\n`; + ev += dtLine('DTSTART', time, zone, ref); + if (isTime(endTime)) ev += dtLine('DTEND', endTime!, zone, ref); + ev += `SUMMARY:${esc(summary)}\r\n`; + if (stay.place_address) ev += `LOCATION:${esc(stay.place_address)}\r\n`; + ev += `END:VEVENT\r\n`; + events.push(ev); + }; + + if (isDate(stay.start_date) && isTime(stay.check_in)) { + marker('checkin', stay.start_date, stay.check_in, `Check-in: ${name}`, stay.check_in_end); + } + if (isDate(checkOutDate) && isTime(stay.check_out)) { + marker('checkout', checkOutDate, stay.check_out, `Check-out: ${name}`); + } + } + // Every referenced zone gets a VTIMEZONE. They are emitted before the first // event so the TZID references resolve; keyed by TZID so a merged calendar // can define each one once. diff --git a/server/src/nest/collections/collections.controller.ts b/server/src/nest/collections/collections.controller.ts index b29306ad8..e1f85a729 100644 --- a/server/src/nest/collections/collections.controller.ts +++ b/server/src/nest/collections/collections.controller.ts @@ -42,6 +42,8 @@ import { CollectionSaveFromTripManyDto, CollectionPlaceUpdateDto, CollectionSetStatusDto, + CollectionSetStatusManyDto, + CollectionSetStatusFromTripDto, CollectionCopyToTripDto, CollectionInviteDto, CollectionInviteActionDto, @@ -132,6 +134,18 @@ export class CollectionsController { return { deleted: this.collections.deletePlacesMany(user.id, body.ids, socketId) }; } + @Post('places/status-many') + @HttpCode(200) + setStatusMany(@CurrentUser() user: User, @Body() body: CollectionSetStatusManyDto, @Headers('x-socket-id') socketId?: string) { + return this.collections.setStatusMany(user.id, body.ids, body.status, socketId); + } + + @Post('places/status-from-trip') + @HttpCode(200) + setStatusFromTrip(@CurrentUser() user: User, @Body() body: CollectionSetStatusFromTripDto, @Headers('x-socket-id') socketId?: string) { + return this.collections.setStatusFromTrip(user.id, body.trip_id, body.place_ids, body.status, socketId); + } + @Patch('places/:pid') updatePlace( @CurrentUser() user: User, diff --git a/server/src/nest/collections/collections.dto.ts b/server/src/nest/collections/collections.dto.ts index 15e966d76..db1fb8a60 100644 --- a/server/src/nest/collections/collections.dto.ts +++ b/server/src/nest/collections/collections.dto.ts @@ -9,6 +9,8 @@ import { collectionSaveFromTripManyRequestSchema, collectionPlaceUpdateRequestSchema, collectionSetStatusRequestSchema, + collectionSetStatusManyRequestSchema, + collectionSetStatusFromTripRequestSchema, collectionCopyToTripRequestSchema, collectionInviteRequestSchema, collectionInviteActionRequestSchema, @@ -36,6 +38,8 @@ export class CollectionSaveFromTripDto extends createZodDto(collectionSaveFromTr export class CollectionSaveFromTripManyDto extends createZodDto(collectionSaveFromTripManyRequestSchema) {} export class CollectionPlaceUpdateDto extends createZodDto(collectionPlaceUpdateRequestSchema) {} export class CollectionSetStatusDto extends createZodDto(collectionSetStatusRequestSchema) {} +export class CollectionSetStatusManyDto extends createZodDto(collectionSetStatusManyRequestSchema) {} +export class CollectionSetStatusFromTripDto extends createZodDto(collectionSetStatusFromTripRequestSchema) {} export class CollectionCopyToTripDto extends createZodDto(collectionCopyToTripRequestSchema) {} export class CollectionInviteDto extends createZodDto(collectionInviteRequestSchema) {} export class CollectionInviteActionDto extends createZodDto(collectionInviteActionRequestSchema) {} diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index 5eca70e7c..d795adf2f 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -5,6 +5,13 @@ import { DatabaseService } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; import { RealtimeService } from '../realtime/realtime.service'; import { reclaimPlaceImage } from '../places/place-image'; +import { + COORD_DEDUP_TOLERANCE, + externalIdsOf, + isPlaceDuplicate, + trackInsertedInDedupSet, + type DedupSet, +} from '../places/places.helpers'; import type { Collection, CollectionDetailResponse, @@ -71,32 +78,6 @@ interface PlaceRow extends CollectionPlace { category_icon?: string | null; } -// --------------------------------------------------------------------------- -// Dedup (collection-scoped ports of placeService helpers) -// --------------------------------------------------------------------------- - -const COORD_DEDUP_TOLERANCE = 0.0001; // ≈ 11 m - -interface DedupSet { - names: Set; - coords: Array<{ lat: number; lng: number }>; -} - -function isCollectionPlaceDuplicate( - candidate: { name: string | null | undefined; lat: number | null | undefined; lng: number | null | undefined }, - dedup: DedupSet, -): boolean { - const normalizedName = candidate.name?.trim().toLowerCase(); - if (normalizedName) return dedup.names.has(normalizedName); - if (candidate.lat != null && candidate.lng != null) { - return dedup.coords.some(c => - Math.abs(c.lat - candidate.lat!) <= COORD_DEDUP_TOLERANCE && - Math.abs(c.lng - candidate.lng!) <= COORD_DEDUP_TOLERANCE, - ); - } - return false; -} - const MAX_LABELS_PER_COLLECTION = 50; /** @@ -776,6 +757,109 @@ export class CollectionsService { return deleted; } + /** + * Set the same status on several saved places at once (#1469). + * + * Same all-or-nothing shape as deletePlacesMany: every id is resolved and + * permission-checked before the first write, so a list the caller may not edit + * cannot leave half the batch applied. Rows that already carry the status are + * counted as untouched rather than rewritten, which keeps updated_at honest. + */ + setStatusMany(userId: number, ids: number[], status: CollectionStatus, socketId?: string): { updated: number } { + const touched = new Set(); + for (const id of ids) { + const collectionId = this.collectionIdOfPlace(id); + this.assertCanEdit(userId, collectionId); + touched.add(collectionId); + } + let updated = 0; + this.db.transaction(() => { + for (const id of ids) { + const res = this.db.run( + "UPDATE collection_places SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status IS NOT ?", + status, id, status, + ); + updated += res.changes; + } + }); + if (updated > 0) touched.forEach(cid => this.notifyCollectionUsers(cid, socketId, 'collections:updated')); + return { updated }; + } + + /** + * Set a status on every saved copy of the given trip places (#1469): "I have + * been here now" said once from the trip, instead of hunting the place down in + * each list it was saved to. + * + * The match is findMembership's, so what gets updated is exactly what the + * place dialog listed as "saved in". Lists the caller may only read are left + * alone rather than refused — a shared list you cannot edit should not stop + * you marking your own. + */ + setStatusFromTrip( + userId: number, + tripId: number, + placeIds: number[], + status: CollectionStatus, + socketId?: string, + ): { updated: number; places: number } { + if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); + + const sources = placeIds.length + ? this.db.all<{ id: number; name: string; lat: number | null; lng: number | null; google_place_id: string | null; google_ftid: string | null; osm_id: string | null }>(` + SELECT id, name, lat, lng, google_place_id, google_ftid, osm_id + FROM places WHERE trip_id = ? AND id IN (${placeIds.map(() => '?').join(',')}) + `, tripId, ...placeIds) + : []; + if (sources.length === 0) return { updated: 0, places: 0 }; + + const editable = this.accessibleCollectionIds(userId).filter(cid => { + const role = this.roleOf(userId, cid); + return role !== null && role !== 'viewer'; + }); + if (editable.length === 0) return { updated: 0, places: 0 }; + + const matched = new Set(); + const placesWithMatch = new Set(); + for (const src of sources) { + for (const row of this.matchingCollectionPlaces(editable, tripId, src)) { + matched.add(row.id); + placesWithMatch.add(src.id); + } + } + if (matched.size === 0) return { updated: 0, places: 0 }; + + const { updated } = this.setStatusMany(userId, [...matched], status, socketId); + return { updated, places: placesWithMatch.size }; + } + + /** + * The saved copies of one trip place. Same signals findMembership uses — a + * provider id, or the same spot within the dedup tolerance — plus the + * source link a place saved out of this very trip already carries, which beats + * inferring anything. A bare name match is left out here for the same reason + * as there: every "Starbucks" in the library would answer to it. + */ + private matchingCollectionPlaces( + collectionIds: number[], + tripId: number, + place: { id: number; lat: number | null; lng: number | null; google_place_id: string | null; google_ftid: string | null; osm_id: string | null }, + ): Array<{ id: number }> { + const conditions: string[] = ['(cp.source_trip_id = ? AND cp.source_place_id = ?)']; + const params: (string | number)[] = [...collectionIds, tripId, place.id]; + if (place.google_place_id) { conditions.push('cp.google_place_id = ?'); params.push(place.google_place_id); } + if (place.google_ftid) { conditions.push('cp.google_ftid = ?'); params.push(place.google_ftid); } + if (place.osm_id) { conditions.push('cp.osm_id = ?'); params.push(place.osm_id); } + if (place.lat != null && place.lng != null) { + conditions.push('(cp.lat IS NOT NULL AND cp.lng IS NOT NULL AND abs(cp.lat - ?) <= ? AND abs(cp.lng - ?) <= ?)'); + params.push(place.lat, COORD_DEDUP_TOLERANCE, place.lng, COORD_DEDUP_TOLERANCE); + } + return this.db.all<{ id: number }>(` + SELECT cp.id FROM collection_places cp + WHERE cp.collection_id IN (${collectionIds.map(() => '?').join(',')}) AND (${conditions.join(' OR ')}) + `, ...params); + } + /** Set (or clear) a saved place's custom thumbnail, reclaiming the previous upload. */ setPlaceImage(userId: number, placeId: number, imageUrl: string | null, socketId?: string): CollectionPlace { const collectionId = this.collectionIdOfPlace(placeId); @@ -823,10 +907,15 @@ export class CollectionsService { sources.push(row); } - // Trip dedup set (mirrors placeService.buildDedupSet). - const existing = this.db.all<{ name: string | null; lat: number | null; lng: number | null }>('SELECT name, lat, lng FROM places WHERE trip_id = ?', body.trip_id); - const dedup: DedupSet = { names: new Set(), coords: [] }; + // Trip dedup set — same helpers the importers use, so a place renamed in the + // trip is still recognised by its provider id when it is copied again (#1550). + const existing = this.db.all<{ + name: string | null; lat: number | null; lng: number | null; + google_place_id: string | null; google_ftid: string | null; osm_id: string | null; + }>('SELECT name, lat, lng, google_place_id, google_ftid, osm_id FROM places WHERE trip_id = ?', body.trip_id); + const dedup: DedupSet = { names: new Set(), coords: [], externalIds: new Set() }; for (const r of existing) { + for (const id of externalIdsOf(r)) dedup.externalIds.add(id); if (r.name) dedup.names.add(r.name.trim().toLowerCase()); else if (r.lat != null && r.lng != null) dedup.coords.push({ lat: r.lat, lng: r.lng }); } @@ -844,7 +933,10 @@ export class CollectionsService { // The whole copy is one logical write — atomic since the post-fold quirk pass. this.db.transaction(() => { for (const s of sources) { - if (!body.force && isCollectionPlaceDuplicate({ name: s.name, lat: s.lat, lng: s.lng }, dedup)) { + if (!body.force && isPlaceDuplicate({ + name: s.name, lat: s.lat, lng: s.lng, + google_place_id: s.google_place_id, google_ftid: s.google_ftid, osm_id: s.osm_id, + }, dedup)) { skipped.push({ id: s.id, name: s.name }); continue; } @@ -861,8 +953,7 @@ export class CollectionsService { const votes = this.db.all<{ user_id: number; rating: number }>('SELECT user_id, rating FROM collection_place_ratings WHERE collection_place_id = ?', s.id); for (const v of votes) if (tripMemberIds.has(v.user_id)) insertRating.run(newPlaceId, v.user_id, v.rating); - if (s.name) dedup.names.add(s.name.trim().toLowerCase()); - else if (s.lat != null && s.lng != null) dedup.coords.push({ lat: s.lat, lng: s.lng }); + trackInsertedInDedupSet(s, dedup); copied++; } }); @@ -896,14 +987,26 @@ export class CollectionsService { } if (conditions.length === 0) return { saved: false, lists: [] }; - const rows = this.db.all<{ place_id: number; collection_id: number; name: string }>(` - SELECT cp.id AS place_id, cp.collection_id, c.name + const rows = this.db.all<{ place_id: number; collection_id: number; name: string; status: CollectionStatus }>(` + SELECT cp.id AS place_id, cp.collection_id, c.name, cp.status FROM collection_places cp JOIN collections c ON c.id = cp.collection_id WHERE cp.collection_id IN (${placeholders}) AND (${conditions.join(' OR ')}) `, ...params); - return { saved: rows.length > 0, lists: rows.map(r => ({ collection_id: r.collection_id, name: r.name, place_id: r.place_id })) }; + return { + saved: rows.length > 0, + lists: rows.map(r => { + const role = this.roleOf(userId, r.collection_id); + return { + collection_id: r.collection_id, + name: r.name, + place_id: r.place_id, + status: r.status ?? 'idea', + can_edit: role !== null && role !== 'viewer', + }; + }), + }; } // ------------------------------------------------------------------------- diff --git a/server/src/nest/oauth/oauth.helpers.ts b/server/src/nest/oauth/oauth.helpers.ts index 6846e886a..49933506f 100644 --- a/server/src/nest/oauth/oauth.helpers.ts +++ b/server/src/nest/oauth/oauth.helpers.ts @@ -9,6 +9,29 @@ import crypto, { createHash, randomBytes } from 'crypto'; export const ACCESS_TOKEN_TTL_S = 60 * 60; // 1 hour export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days rolling +/** + * How long a just-rotated refresh token still counts as "in flight" rather than + * as a replay (#1007). + * + * Rotation is a race by construction: two clients sharing one token — several + * MCP sessions, a client that retried after a timeout — can present it within + * the same second. Without leeway the second one is read as theft and the whole + * chain is revoked, which is why a handful of MCP tabs would all pop a login + * window every day. RFC 9700 §4.14.2 names exactly this and asks for a short + * grace period. Short is the point: it is measured from the *first* rotation and + * does not slide, so a stolen token still gets caught as soon as it is used + * outside the window. + */ +export const REFRESH_ROTATION_GRACE_MS = 30 * 1000; + +/** SQLite writes CURRENT_TIMESTAMP as UTC without a zone; JS would read it as local. */ +export function parseSqliteUtc(ts: string | null | undefined): Date | null { + if (!ts) return null; + const iso = ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z'; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d; +} + // PKCE format (RFC 7636) export const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/; export const CODE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/; diff --git a/server/src/nest/oauth/oauth.service.ts b/server/src/nest/oauth/oauth.service.ts index 14de55807..b1f2e32e9 100644 --- a/server/src/nest/oauth/oauth.service.ts +++ b/server/src/nest/oauth/oauth.service.ts @@ -18,10 +18,12 @@ import { ACCESS_TOKEN_TTL_S, CODE_CHALLENGE_RE, CODE_VERIFIER_RE, + REFRESH_ROTATION_GRACE_MS, REFRESH_TOKEN_TTL_MS, generateAccessToken, generateRefreshToken, hashToken, + parseSqliteUtc, timingSafeEqualHex, type OAuthClientRow, type OAuthTokenRow, @@ -422,6 +424,27 @@ export class OauthService { return ids; } + /** + * True when a revoked refresh token is the loser of a concurrent rotation + * rather than a replayed one. + * + * Two conditions, and both matter. The revocation has to be recent, and it has + * to have produced a successor that is still alive. The second one is what + * keeps the window from re-opening a session someone deliberately closed: an + * explicit revoke leaves no live child, and a chain revoked after a real replay + * has every child revoked with it, so neither can slip through here. + */ + private isConcurrentRotation(row: OAuthTokenRow): boolean { + const revokedAt = parseSqliteUtc(row.revoked_at); + if (!revokedAt) return false; + if (Date.now() - revokedAt.getTime() > REFRESH_ROTATION_GRACE_MS) return false; + const successor = this.db.get<{ id: number }>( + 'SELECT id FROM oauth_tokens WHERE parent_token_id = ? AND revoked_at IS NULL LIMIT 1', + row.id, + ); + return !!successor; + } + refreshTokens( rawRefreshToken: string, clientId: string, @@ -447,6 +470,22 @@ export class OauthService { // ---- Replay detection (C3) ---- if (row.revoked_at) { + // …unless the rotation that revoked it happened seconds ago and produced a + // successor that is still alive. That is two clients refreshing at once, + // not theft (#1007): they share one token, both post it, and the loser used + // to take the whole chain down with it. Issue a sibling pair off the same + // parent so each client walks away with its own token. + if (this.isConcurrentRotation(row)) { + const tokens = this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null); + this.audit.writeAudit({ + userId: row.user_id, + action: 'oauth.token.refresh', + details: { client_id: clientId, concurrent: true }, + ip, + }); + return { tokens }; + } + // A revoked refresh token was replayed — assume token theft. Cascade-revoke the chain. const rootId = this.findChainRoot(row.id); this.revokeChain(rootId); diff --git a/server/src/nest/places/places.helpers.ts b/server/src/nest/places/places.helpers.ts index 2e6c2b702..e55f66124 100644 --- a/server/src/nest/places/places.helpers.ts +++ b/server/src/nest/places/places.helpers.ts @@ -118,17 +118,48 @@ export function reclaimPhotoCache(cache: PlacePhotoCacheService, googlePlaceId: export interface DedupSet { names: Set; coords: Array<{ lat: number; lng: number }>; + /** Provider ids (google_place_id, google_ftid, osm_id) of the places already in the trip. */ + externalIds: Set; +} + +/** The provider ids a candidate can be recognised by, ignoring blanks. */ +export function externalIdsOf(candidate: { + google_place_id?: string | null; + google_ftid?: string | null; + osm_id?: string | null; +}): string[] { + return [candidate.google_place_id, candidate.google_ftid, candidate.osm_id] + .filter((id): id is string => typeof id === 'string' && id.trim() !== '') + .map(id => id.trim()); } /** * Returns true if a candidate place is already represented in the dedup set. - * Named places match by case-insensitive name; unnamed places fall back to - * coordinate proximity. + * + * The provider id comes first, because it is the only part of a place that + * survives the user editing it. Re-importing a Google Maps list used to duplicate + * every place someone had renamed (#1550): the name no longer matched, and the + * coordinate fallback never ran for a named candidate. The ids were already being + * stored, and even backfilled onto matched places, they were simply never read. + * + * Name and coordinates keep their previous roles below it, deliberately unchanged: + * widening the coordinate check to named places would merge the restaurant and the + * bar in the same building, which is a worse failure than the one being fixed. */ export function isPlaceDuplicate( - candidate: { name: string | null | undefined; lat: number | null; lng: number | null }, + candidate: { + name: string | null | undefined; + lat: number | null; + lng: number | null; + google_place_id?: string | null; + google_ftid?: string | null; + osm_id?: string | null; + }, dedup: DedupSet, ): boolean { + for (const id of externalIdsOf(candidate)) { + if (dedup.externalIds.has(id)) return true; + } const normalizedName = candidate.name?.trim().toLowerCase(); if (normalizedName) return dedup.names.has(normalizedName); if (candidate.lat != null && candidate.lng != null) { @@ -143,9 +174,17 @@ export function isPlaceDuplicate( /** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */ export function trackInsertedInDedupSet( - place: { name: string | null | undefined; lat: number | null; lng: number | null }, + place: { + name: string | null | undefined; + lat: number | null; + lng: number | null; + google_place_id?: string | null; + google_ftid?: string | null; + osm_id?: string | null; + }, dedup: DedupSet, ): void { + for (const id of externalIdsOf(place)) dedup.externalIds.add(id); const normalizedName = place.name?.trim().toLowerCase(); if (normalizedName) { dedup.names.add(normalizedName); diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 141a5e93f..26cdf9b8f 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -33,6 +33,7 @@ import { MAX_LIST_RESPONSE_BYTES, googleMapsFeatureIdFromItem, gpxParser, + externalIdsOf, isPlaceDuplicate, kmlParser, mapWithConcurrency, @@ -422,25 +423,46 @@ export class PlacesService { /** Build a lookup of names/coords for places already in a trip. */ private buildDedupSet(tripId: string): DedupSet { - const rows = this.dbs.all<{ name: string | null; lat: number | null; lng: number | null }>( - 'SELECT name, lat, lng FROM places WHERE trip_id = ?', tripId, + const rows = this.dbs.all<{ + name: string | null; lat: number | null; lng: number | null; + google_place_id: string | null; google_ftid: string | null; osm_id: string | null; + }>( + 'SELECT name, lat, lng, google_place_id, google_ftid, osm_id FROM places WHERE trip_id = ?', tripId, ); const names = new Set(); const coords: Array<{ lat: number; lng: number }> = []; + // Provider ids are collected for every place, named or not: they are what lets a + // renamed place still be recognised on a re-import (#1550). + const externalIds = new Set(); for (const row of rows) { + for (const id of externalIdsOf(row)) externalIds.add(id); if (row.name) { names.add(row.name.trim().toLowerCase()); } else if (row.lat != null && row.lng != null) { coords.push({ lat: row.lat, lng: row.lng }); } } - return { names, coords }; + return { names, coords, externalIds }; } private findDuplicatePlace( tripId: string, - place: { name: string | null | undefined; lat: number | null; lng: number | null }, + place: { + name: string | null | undefined; lat: number | null; lng: number | null; + google_place_id?: string | null; google_ftid?: string | null; osm_id?: string | null; + }, ): { id: number; google_ftid: string | null } | null { + // Same order as isPlaceDuplicate: the provider id wins, because it is what + // survives a rename (#1550). + for (const id of externalIdsOf(place)) { + const byId = this.dbs.get<{ id: number; google_ftid: string | null }>(` + SELECT id, google_ftid FROM places + WHERE trip_id = ? AND (google_place_id = ? OR google_ftid = ? OR osm_id = ?) + ORDER BY id ASC + LIMIT 1 + `, tripId, id, id, id); + if (byId) return byId; + } const normalizedName = place.name?.trim().toLowerCase(); if (normalizedName) { const duplicate = this.dbs.get<{ id: number; google_ftid: string | null }>(` @@ -901,7 +923,7 @@ export class PlacesService { let skipped = 0; this.dbs.transaction(() => { for (const p of places) { - if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { + if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng, google_ftid: p.googleFtid }, dedup)) { const duplicate = this.findDuplicatePlace(tripId, p); if (duplicate && !duplicate.google_ftid && p.googleFtid) { updateGoogleFtidStmt.run(p.googleFtid, duplicate.id); @@ -912,7 +934,7 @@ export class PlacesService { const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes, p.googleFtid); const place = this.dbs.getPlaceWithTags(Number(result.lastInsertRowid))!; created.push(place); - trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); + trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng, google_ftid: p.googleFtid }, dedup); } }); diff --git a/server/tests/e2e/collections.e2e.test.ts b/server/tests/e2e/collections.e2e.test.ts index bf3e7b1a4..716a988b9 100644 --- a/server/tests/e2e/collections.e2e.test.ts +++ b/server/tests/e2e/collections.e2e.test.ts @@ -291,6 +291,41 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => expect(imported.body.skipped.map((s: { name: string }) => s.name)).toEqual(['Musée Rodin']); }); + // ── bulk visited (#1469) ───────────────────────────────────────────────── + it('COLLECTIONS-E2E-073: a trip selection can be marked visited in every list holding it', async () => { + const paris = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Paris' })).body; + const museums = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Museums' })).body; + const trip = createTrip(db as never, ownerId); + const louvre = createPlace(db as never, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 }); + + for (const col of [paris, museums]) { + await request(server).post('/api/addons/collections/places/from-trip') + .set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, source_trip_id: trip.id, source_place_id: louvre.id }); + } + + const res = await request(server).post('/api/addons/collections/places/status-from-trip') + .set('Cookie', sessionCookie(ownerId)).send({ trip_id: trip.id, place_ids: [louvre.id], status: 'visited' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ updated: 2, places: 1 }); + + // …and the dialog's per-list view now says so. + const membership = await request(server).get('/api/addons/collections/membership') + .query({ lat: 48.8606, lng: 2.3376 }).set('Cookie', sessionCookie(ownerId)); + expect(membership.body.lists.map((l: { status: string }) => l.status)).toEqual(['visited', 'visited']); + }); + + it('COLLECTIONS-E2E-074: status-many refuses a list the caller may only read', async () => { + const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Shared' })).body; + await request(server).post('/api/addons/collections/invite').set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, user_id: otherId, role: 'viewer' }); + await request(server).post('/api/addons/collections/invite/accept').set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id }); + const place = (await request(server).post('/api/addons/collections/places') + .set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Louvre' })).body.place; + + const res = await request(server).post('/api/addons/collections/places/status-many') + .set('Cookie', sessionCookie(otherId)).send({ ids: [place.id], status: 'visited' }); + expect(res.status).toBe(403); + }); + it('COLLECTIONS-E2E-072: a trip the caller cannot see is a 404, and so is a foreign list', async () => { const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Private' })).body; const foreignTrip = createTrip(db as never, otherId); diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index d04448c01..4c06f37be 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -1303,6 +1303,17 @@ describe('M7 — Cookie-only auth on privileged OAuth endpoints', () => { }); describe('C3 — Refresh token replay detection', () => { + /** + * Push a rotation out of the concurrency grace window (#1007) so a replay + * below still describes theft — a token used minutes later — rather than two + * clients refreshing at the same moment. + */ + function agePastGrace(rawRefreshToken: string) { + const hash = crypto.createHash('sha256').update(rawRefreshToken).digest('hex'); + const old = new Date(Date.now() - 5 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19); + testDb.prepare('UPDATE oauth_tokens SET revoked_at = ? WHERE refresh_token_hash = ?').run(old, hash); + } + it('OAUTH-SEC-012 — replaying a rotated (old) refresh token returns invalid_grant', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); @@ -1340,6 +1351,7 @@ describe('C3 — Refresh token replay detection', () => { expect(t2.status).toBe(200); // Replay the original (now rotated/revoked) refresh token — must be rejected + agePastGrace(originalRefreshToken); const t3 = await request(app).post('/oauth/token').send({ grant_type: 'refresh_token', client_id: r.client!.client_id, @@ -1350,6 +1362,46 @@ describe('C3 — Refresh token replay detection', () => { expect(t3.body.error).toBe('invalid_grant'); }); + it('OAUTH-SEC-012b — two clients refreshing the same token at once both keep working (#1007)', async () => { + const { user } = createUser(testDb); + const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); + const { verifier, challenge } = makePkce(); + + const code = createAuthCode({ + clientId: r.client!.client_id as string, + userId: user.id, + redirectUri: 'https://app.example.com/cb', + scopes: ['trips:read'], + codeChallenge: challenge, + codeChallengeMethod: 'S256', + resource: null, + }); + const t1 = await request(app).post('/oauth/token').send({ + grant_type: 'authorization_code', + client_id: r.client!.client_id, + client_secret: r.client!.client_secret, + code, + redirect_uri: 'https://app.example.com/cb', + code_verifier: verifier, + }); + const shared = t1.body.refresh_token; + + const refresh = () => request(app).post('/oauth/token').send({ + grant_type: 'refresh_token', + client_id: r.client!.client_id, + client_secret: r.client!.client_secret, + refresh_token: shared, + }); + + // The second MCP session posts the token its sibling has just spent. + const first = await refresh(); + const second = await refresh(); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(second.body.refresh_token).not.toBe(first.body.refresh_token); + }); + it('OAUTH-SEC-013 — replaying old token also invalidates the new chain', async () => { const { user } = createUser(testDb); const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']); @@ -1385,6 +1437,7 @@ describe('C3 — Refresh token replay detection', () => { const newRefreshToken = t2.body.refresh_token; // Replay original — triggers chain revocation + agePastGrace(originalRefreshToken); await request(app).post('/oauth/token').send({ grant_type: 'refresh_token', client_id: r.client!.client_id, diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index db4c4ed1e..f3ea46903 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -669,6 +669,143 @@ describe('exportICS', () => { }); }); +// ── Accommodations in the feed (#1586) ─────────────────────────────────────── + +describe('accommodations', () => { + /** A stay with its linked hotel reservation, the shape createAccommodation writes. */ + const createStay = ( + tripId: number, + opts: { + start: string | null; + end?: string | null; + check_in?: string | null; + check_in_end?: string | null; + check_out?: string | null; + title?: string; + withReservation?: boolean; + lat?: number; + lng?: number; + }, + ) => { + const place = createPlace(testDb, tripId, { + name: opts.title ?? 'Hotel Bellevue', + lat: opts.lat ?? 48.8566, + lng: opts.lng ?? 2.3522, + }); + testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('1 Rue de Rivoli', place.id); + const startDay = createDay(testDb, tripId, { date: opts.start ?? undefined }); + const endDay = opts.end === undefined + ? startDay + : createDay(testDb, tripId, { date: opts.end ?? undefined }); + const stayId = testDb.prepare(` + INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + tripId, place.id, startDay.id, endDay.id, + opts.check_in ?? null, opts.check_in_end ?? null, opts.check_out ?? null, + ).lastInsertRowid as number; + + if (opts.withReservation !== false) { + testDb.prepare(` + INSERT INTO reservations (trip_id, day_id, title, reservation_time, status, type, accommodation_id) + VALUES (?, ?, ?, ?, 'confirmed', 'hotel', ?) + `).run(tripId, startDay.id, opts.title ?? 'Hotel Bellevue', opts.start, String(stayId)); + } + return { stayId, placeId: place.id }; + }; + + it('CAL-025: a stay covers every night as one all-day event, not just the arrival day', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + createStay(trip.id, { start: '2026-07-07', end: '2026-07-12' }); + + const { ics } = svc.exportICS(trip.id); + + // DTEND is exclusive, so the day after checkout. + expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713'); + expect(ics).toContain('SUMMARY:Hotel Bellevue'); + }); + + it('CAL-026: check-in and check-out become their own timed events in the stay zone', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + createStay(trip.id, { + start: '2026-07-07', end: '2026-07-12', + check_in: '15:00', check_in_end: '22:00', check_out: '11:00', + }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue'); + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T150000'); + expect(ics).toContain('DTEND;TZID=Europe/Paris:20260707T220000'); + expect(ics).toContain('SUMMARY:Check-out: Hotel Bellevue'); + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260712T110000'); + expect(ics).toContain('LOCATION:1 Rue de Rivoli'); + expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris'); + }); + + it('CAL-027: a stay without times emits the all-day range and nothing else', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + createStay(trip.id, { start: '2026-07-07', end: '2026-07-12' }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).not.toContain('Check-in'); + expect(ics).not.toContain('Check-out'); + }); + + it('CAL-028: a stay whose end day lost its date falls back to the arrival day', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + createStay(trip.id, { start: '2026-07-07', end: null, check_out: '11:00' }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260708'); + expect(ics).toContain('SUMMARY:Check-out: Hotel Bellevue'); + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T110000'); + }); + + it('CAL-029: a stay with no reservation still contributes its check-in event', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + createStay(trip.id, { start: '2026-07-07', end: '2026-07-09', check_in: '15:00', withReservation: false }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue'); + // No stay event without a reservation to carry it — the accommodation itself + // has no title, notes or confirmation of its own to show. + expect(ics).not.toContain('DTEND;VALUE=DATE:20260710'); + }); + + it('CAL-030: a hotel reservation without a stay keeps its old single-day event', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris' }); + const reservation = createReservation(testDb, trip.id, { title: 'Airbnb', type: 'hotel' }); + testDb.prepare('UPDATE reservations SET reservation_time=? WHERE id=?').run('2026-07-07', reservation.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;VALUE=DATE:20260707'); + expect(ics).not.toContain('DTEND;VALUE=DATE'); + }); + + it('CAL-031: a dateless stay is skipped instead of emitting a broken event', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Someday' }); + createStay(trip.id, { start: null, end: null, check_in: '15:00', check_out: '11:00' }); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).not.toContain('Check-in'); + expect(ics).not.toContain('Check-out'); + expect(ics).not.toContain('DTSTART;VALUE=DATE:'); + }); +}); + describe('folded quirk branches', () => { it('TRIP-SVC-048: exportICS renders untimed/notes all-day summaries, multi-leg routes, endpoint routes and train/location fields', () => { const { user } = createUser(testDb); diff --git a/server/tests/unit/nest/collections.service.test.ts b/server/tests/unit/nest/collections.service.test.ts index 98319ccf3..def035c82 100644 --- a/server/tests/unit/nest/collections.service.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -853,3 +853,109 @@ describe('atomic bulk writes (post-fold quirk fixes)', () => { expect(broadcastToUser).toHaveBeenCalledWith(u.id, expect.objectContaining({ type: 'collections:updated' }), 'sock-2'); }); }); + +// ── Bulk "visited" from a trip (#1469) ─────────────────────────────────────── + +/** The stored status of a saved place, read straight off the row. */ +function statusOf(placeId: number): string { + return (testDb.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId) as { status: string }).status; +} + +describe('bulk status', () => { + it('COLLECTIONS-SVC-093: setStatusMany writes one status across lists and counts only real changes', () => { + const u = createUser(testDb).user; + const a = svc.createCollection(u.id, { name: 'A' }); + const b = svc.createCollection(u.id, { name: 'B' }); + const pa = svc.savePlace(u.id, { collection_id: a.id, name: 'Louvre' }).place!; + const pb = svc.savePlace(u.id, { collection_id: b.id, name: 'Louvre' }).place!; + svc.setStatus(u.id, pb.id, 'visited'); + + // pb is already visited, so only pa is a change. + expect(svc.setStatusMany(u.id, [pa.id, pb.id], 'visited')).toEqual({ updated: 1 }); + expect(statusOf(pa.id)).toBe('visited'); + expect(svc.setStatusMany(u.id, [pa.id, pb.id], 'visited')).toEqual({ updated: 0 }); + }); + + it('COLLECTIONS-SVC-094: setStatusMany is all-or-nothing — a read-only list stops the batch', () => { + const owner = createUser(testDb).user; + const viewer = createUser(testDb).user; + const mine = svc.createCollection(viewer.id, { name: 'Mine' }); + const readonly = svc.createCollection(owner.id, { name: 'Shared' }); + addMember(readonly.id, viewer.id, 'viewer'); + const p1 = svc.savePlace(viewer.id, { collection_id: mine.id, name: 'Louvre' }).place!; + const p2 = svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre' }).place!; + + expect(() => svc.setStatusMany(viewer.id, [p1.id, p2.id], 'visited')).toThrow('You have read-only access to this list'); + expect(statusOf(p1.id)).toBe('idea'); + }); + + it('COLLECTIONS-SVC-095: setStatusFromTrip marks every saved copy of the selected trip places', () => { + const u = createUser(testDb).user; + createCategory(testDb); + const trip = createTrip(testDb, u.id); + const louvre = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 }); + const orsay = createPlace(testDb, trip.id, { name: 'Orsay', lat: 48.86, lng: 2.3266 }); + const a = svc.createCollection(u.id, { name: 'Paris' }); + const b = svc.createCollection(u.id, { name: 'Museums' }); + svc.saveFromTripPlace(u.id, a.id, trip.id, louvre.id); + svc.saveFromTripPlace(u.id, b.id, trip.id, louvre.id); + svc.saveFromTripPlace(u.id, a.id, trip.id, orsay.id); + + expect(svc.setStatusFromTrip(u.id, trip.id, [louvre.id], 'visited')).toEqual({ updated: 2, places: 1 }); + const statuses = testDb.prepare('SELECT status FROM collection_places ORDER BY id').all() as { status: string }[]; + expect(statuses.map(s => s.status)).toEqual(['visited', 'visited', 'idea']); + }); + + it('COLLECTIONS-SVC-096: a place renamed in the list is still found by its source link', () => { + const u = createUser(testDb).user; + createCategory(testDb); + const trip = createTrip(testDb, u.id); + const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo', lat: 41.88, lng: 12.47 }); + const col = svc.createCollection(u.id, { name: 'Rome' }); + const saved = svc.saveFromTripPlace(u.id, col.id, trip.id, place.id).place!; + // Renamed on both sides, and moved far enough that coordinates cannot match. + svc.updatePlace(u.id, saved.id, { name: 'Dinner spot', lat: 45, lng: 9 }); + testDb.prepare('UPDATE places SET name = ? WHERE id = ?').run('Enzo', place.id); + + expect(svc.setStatusFromTrip(u.id, trip.id, [place.id], 'visited')).toEqual({ updated: 1, places: 1 }); + }); + + it('COLLECTIONS-SVC-097: a trip the caller cannot see is a 404, and unsaved places are a no-op', () => { + const u = createUser(testDb).user; + const stranger = createUser(testDb).user; + createCategory(testDb); + const trip = createTrip(testDb, u.id); + const place = createPlace(testDb, trip.id, { name: 'Louvre' }); + + expect(() => svc.setStatusFromTrip(stranger.id, trip.id, [place.id], 'visited')).toThrow('Trip not found'); + expect(svc.setStatusFromTrip(u.id, trip.id, [place.id], 'visited')).toEqual({ updated: 0, places: 0 }); + }); + + it('COLLECTIONS-SVC-098: lists the caller may only read are skipped, not refused', () => { + const owner = createUser(testDb).user; + const viewer = createUser(testDb).user; + createCategory(testDb); + const trip = createTrip(testDb, viewer.id); + const place = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 }); + const readonly = svc.createCollection(owner.id, { name: 'Shared' }); + addMember(readonly.id, viewer.id, 'viewer'); + const theirs = svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre', lat: 48.8606, lng: 2.3376 }).place!; + + expect(svc.setStatusFromTrip(viewer.id, trip.id, [place.id], 'visited')).toEqual({ updated: 0, places: 0 }); + expect(statusOf(theirs.id)).toBe('idea'); + }); + + it('COLLECTIONS-SVC-099: findMembership reports the per-list status and edit right', () => { + const owner = createUser(testDb).user; + const viewer = createUser(testDb).user; + const readonly = svc.createCollection(owner.id, { name: 'Shared' }); + addMember(readonly.id, viewer.id, 'viewer'); + const saved = svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre', google_place_id: 'gp-9' }).place!; + svc.setStatus(owner.id, saved.id, 'visited'); + + expect(svc.findMembership(viewer.id, { google_place_id: 'gp-9' }).lists).toEqual([ + { collection_id: readonly.id, name: 'Shared', place_id: saved.id, status: 'visited', can_edit: false }, + ]); + expect(svc.findMembership(owner.id, { google_place_id: 'gp-9' }).lists[0].can_edit).toBe(true); + }); +}); diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts index b5115cd28..a92654af9 100644 --- a/server/tests/unit/nest/oauth.service.test.ts +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -812,6 +812,17 @@ describe('getUserByAccessToken — includes clientId (C2)', () => { // C3 — Refresh token replay detection and chain revocation // --------------------------------------------------------------------------- +/** + * Push a token's rotation out of the concurrency grace window (#1007), so the + * replay cases below still describe theft — a token used minutes later — rather + * than two clients racing. + */ +function agePastGrace(rawRefreshToken: string) { + const hash = crypto.createHash('sha256').update(rawRefreshToken).digest('hex'); + const old = new Date(Date.now() - 5 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19); + testDb.prepare('UPDATE oauth_tokens SET revoked_at = ? WHERE refresh_token_hash = ?').run(old, hash); +} + describe('refreshTokens — replay detection (C3)', () => { it('replaying a revoked refresh token returns invalid_grant', () => { const { user } = createUser(testDb); @@ -825,7 +836,9 @@ describe('refreshTokens — replay detection (C3)', () => { expect(rotateResult.error).toBeUndefined(); const { refresh_token: secondRefresh } = rotateResult.tokens!; - // Replay the FIRST (now revoked) refresh token + // Replay the FIRST (now revoked) refresh token, long enough after the + // rotation that it cannot be a concurrent refresh. + agePastGrace(firstRefresh); const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length; const replayResult = refreshTokens(firstRefresh, clientId, rawSecret); expect(replayResult.error).toBe('invalid_grant'); @@ -847,6 +860,7 @@ describe('refreshTokens — replay detection (C3)', () => { const { access_token: access2, refresh_token: second } = r1.tokens!; // Replay first (revoked) refresh token → chain revoke + agePastGrace(first); refreshTokens(first, clientId, rawSecret); // The rotated access token should also be dead now @@ -873,6 +887,7 @@ describe('refreshTokens — replay detection (C3)', () => { const { refresh_token: third } = r2.tokens!; // Replay the first revoked token → revokes chain containing first+second+third + agePastGrace(first); refreshTokens(first, clientId, rawSecret); // third should now be revoked too (it's in the same chain) @@ -881,6 +896,87 @@ describe('refreshTokens — replay detection (C3)', () => { }); }); +// --------------------------------------------------------------------------- +// #1007 — Concurrent rotation is not a replay +// --------------------------------------------------------------------------- + +describe('refreshTokens — concurrent rotation grace (#1007)', () => { + const setup = () => { + const { user } = createUser(testDb); + const created = makeClient(user.id); + return { + user, + clientId: created.client!.client_id as string, + rawSecret: created.client!.client_secret as string, + }; + }; + + it('OAUTH-GRACE-001: two clients refreshing the same token both get tokens', () => { + const { user, clientId, rawSecret } = setup(); + const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']); + + const first = refreshTokens(shared, clientId, rawSecret); + const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length; + const second = refreshTokens(shared, clientId, rawSecret); + + expect(first.error).toBeUndefined(); + expect(second.error).toBeUndefined(); + expect(second.tokens!.refresh_token).not.toBe(first.tokens!.refresh_token); + // The whole point: no chain revocation, no torn-down MCP sessions, no login window. + expect(vi.mocked(revokeUserSessionsForClient).mock.calls.length).toBe(callsBefore); + expect(getUserByAccessToken(first.tokens!.access_token)).not.toBeNull(); + expect(getUserByAccessToken(second.tokens!.access_token)).not.toBeNull(); + }); + + it('OAUTH-GRACE-002: both successors keep working afterwards', () => { + const { user, clientId, rawSecret } = setup(); + const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']); + const a = refreshTokens(shared, clientId, rawSecret).tokens!; + const b = refreshTokens(shared, clientId, rawSecret).tokens!; + + expect(refreshTokens(a.refresh_token, clientId, rawSecret).error).toBeUndefined(); + expect(refreshTokens(b.refresh_token, clientId, rawSecret).error).toBeUndefined(); + }); + + it('OAUTH-GRACE-003: the same token replayed after the window is still theft', () => { + const { user, clientId, rawSecret } = setup(); + const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']); + const rotated = refreshTokens(shared, clientId, rawSecret).tokens!; + + agePastGrace(shared); + const replay = refreshTokens(shared, clientId, rawSecret); + + expect(replay.error).toBe('invalid_grant'); + expect(refreshTokens(rotated.refresh_token, clientId, rawSecret).error).toBe('invalid_grant'); + }); + + it('OAUTH-GRACE-004: a token revoked by logout is not re-opened by the window', () => { + const { user, clientId, rawSecret } = setup(); + const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']); + + // Explicit revocation leaves no successor, so there is nothing to be + // concurrent with — the grace must not resurrect it. + revokeToken(shared, clientId, user.id); + const result = refreshTokens(shared, clientId, rawSecret); + + expect(result.error).toBe('invalid_grant'); + }); + + it('OAUTH-GRACE-005: a chain killed by a real replay stays dead inside the window', () => { + const { user, clientId, rawSecret } = setup(); + const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']); + const second = refreshTokens(first, clientId, rawSecret).tokens!; + + // Real theft: the first token turns up again once the window has passed. + agePastGrace(first); + expect(refreshTokens(first, clientId, rawSecret).error).toBe('invalid_grant'); + + // The successor was revoked with the chain, so presenting it now must not + // find a live child and slip through as "concurrent". + expect(refreshTokens(second.refresh_token, clientId, rawSecret).error).toBe('invalid_grant'); + }); +}); + // --------------------------------------------------------------------------- // H1 — PKCE code_challenge / code_verifier format validation // --------------------------------------------------------------------------- diff --git a/server/tests/unit/nest/places.helpers.test.ts b/server/tests/unit/nest/places.helpers.test.ts index 03a11c12f..d560706f4 100644 --- a/server/tests/unit/nest/places.helpers.test.ts +++ b/server/tests/unit/nest/places.helpers.test.ts @@ -21,6 +21,7 @@ import { COORD_DEDUP_TOLERANCE, googleMapsFeatureIdFromItem, googleMapsHexId, + externalIdsOf, isPlaceDuplicate, KMZ_DECOMPRESSED_SIZE_LIMIT, mapWithConcurrency, @@ -78,7 +79,7 @@ describe('unpackKmzToKml', () => { // ── Import dedup predicates ─────────────────────────────────────────────────── -const emptyDedup = (): DedupSet => ({ names: new Set(), coords: [] }); +const emptyDedup = (): DedupSet => ({ names: new Set(), coords: [], externalIds: new Set() }); describe('isPlaceDuplicate / trackInsertedInDedupSet', () => { it('matches a named place case- and whitespace-insensitively', () => { @@ -107,6 +108,45 @@ describe('isPlaceDuplicate / trackInsertedInDedupSet', () => { it('a candidate with neither a name nor coordinates is never a duplicate', () => { expect(isPlaceDuplicate({ name: null, lat: null, lng: null }, emptyDedup())).toBe(false); }); + + // #1550 — the reported bug: rename an imported place, re-import the list, get a twin. + it('recognises a renamed place by its provider id', () => { + const dedup = emptyDedup(); + trackInsertedInDedupSet({ name: 'Trattoria da Enzo', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' }, dedup); + // The user renamed it in TREK; the list still calls it what Google calls it. + dedup.names.delete('trattoria da enzo'); + dedup.names.add('dinner tuesday'); + expect(isPlaceDuplicate({ name: 'Trattoria da Enzo', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' }, dedup)).toBe(true); + }); + + it('matches on any of the three id columns, and ignores blank ones', () => { + const dedup = emptyDedup(); + trackInsertedInDedupSet({ name: 'A', lat: null, lng: null, google_place_id: 'ChIJ_a' }, dedup); + trackInsertedInDedupSet({ name: 'B', lat: null, lng: null, osm_id: 'node/42' }, dedup); + expect(isPlaceDuplicate({ name: 'renamed', lat: null, lng: null, google_place_id: 'ChIJ_a' }, dedup)).toBe(true); + expect(isPlaceDuplicate({ name: 'renamed', lat: null, lng: null, osm_id: 'node/42' }, dedup)).toBe(true); + expect(isPlaceDuplicate({ name: 'renamed', lat: null, lng: null, google_ftid: ' ' }, dedup)).toBe(false); + }); + + it('keeps two different places in the same building apart', () => { + const dedup = emptyDedup(); + // Identical coordinates, different ids: the restaurant and the bar downstairs. + trackInsertedInDedupSet({ name: 'Rooftop Bar', lat: 52.52, lng: 13.405, google_ftid: '0xaa:0xbb' }, dedup); + expect(isPlaceDuplicate({ name: 'Ground Floor Diner', lat: 52.52, lng: 13.405, google_ftid: '0xcc:0xdd' }, dedup)).toBe(false); + }); + + it('leaves id-less imports on their old behaviour', () => { + const dedup = emptyDedup(); + trackInsertedInDedupSet({ name: 'Colosseum', lat: 41.89, lng: 12.49 }, dedup); + expect(dedup.externalIds.size).toBe(0); + expect(isPlaceDuplicate({ name: 'colosseum', lat: null, lng: null }, dedup)).toBe(true); + expect(isPlaceDuplicate({ name: 'Colosseum renamed', lat: 41.89, lng: 12.49 }, dedup)).toBe(false); + }); + + it('externalIdsOf trims and drops empties', () => { + expect(externalIdsOf({ google_place_id: ' ChIJ ', google_ftid: '', osm_id: null })).toEqual(['ChIJ']); + expect(externalIdsOf({})).toEqual([]); + }); }); // ── Google Maps feature ids ─────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index 44d71e456..77bde3de3 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -665,6 +665,58 @@ describe('importGoogleList', () => { expect(row.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93'); }); + it('PLACE-SVC-028d — a renamed place is not re-imported as a twin (#1550)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const listPayload = [ + [null, null, null, null, 'My Test List', null, null, null, [ + [null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"], + ]], + ]; + const respond = () => vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'prefix\n' + JSON.stringify(listPayload), + })); + const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; + + respond(); + const first = await svc.importGoogleList(String(trip.id), url) as any; + expect(first.places).toHaveLength(1); + + // What the reporter does: rename it to something they can actually read, and + // move it far enough that the coordinate fallback would not save us either. + testDb.prepare('UPDATE places SET name = ?, lat = ?, lng = ? WHERE id = ?') + .run('Saturday market', 43.6, -80.6, first.places[0].id); + + respond(); + const second = await svc.importGoogleList(String(trip.id), url) as any; + expect(second.places).toHaveLength(0); + expect(second.skipped).toBe(1); + expect(testDb.prepare('SELECT COUNT(*) c FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ c: 1 }); + }); + + it('PLACE-SVC-028e — two places at the same coordinates still both import', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + // A bar and a diner in one building: same spot, different feature ids. + const listPayload = [ + [null, null, null, null, 'One Building', null, null, null, [ + [null, [null, null, null, null, 'Same street 1', [null, null, 52.52, 13.405], ['1', '2']], 'Rooftop Bar'], + [null, [null, null, null, null, 'Same street 1', [null, null, 52.52, 13.405], ['3', '4']], 'Ground Floor Diner'], + ]], + ]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'prefix\n' + JSON.stringify(listPayload), + })); + + const result = await svc.importGoogleList(String(trip.id), 'https://www.google.com/maps/placelists/list/ABC123DEF456') as any; + expect(result.places).toHaveLength(2); + expect(result.skipped).toBe(0); + }); + it('PLACE-SVC-029 — returns error when list items array is empty', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/shared/src/collection/collection.schema.ts b/shared/src/collection/collection.schema.ts index 51d073dbb..5137129e0 100644 --- a/shared/src/collection/collection.schema.ts +++ b/shared/src/collection/collection.schema.ts @@ -206,6 +206,31 @@ export type CollectionPlaceUpdateRequest = z.infer; +/** Set one status on several saved places at once — the place dialog's "mark + * visited everywhere it is saved" (#1469). Ids are collection_places ids. */ +export const collectionSetStatusManyRequestSchema = z.object({ + ids: z.array(z.number()).min(1).max(1000), + status: collectionStatusSchema, +}); +export type CollectionSetStatusManyRequest = z.infer; + +/** Same, but naming the places by their TRIP ids: the server resolves each one to + * the lists it is saved in. Drives the planner's bulk "mark as visited" (#1469). */ +export const collectionSetStatusFromTripRequestSchema = z.object({ + trip_id: z.number(), + place_ids: z.array(z.number()).min(1).max(1000), + status: collectionStatusSchema, +}); +export type CollectionSetStatusFromTripRequest = z.infer; + +export const collectionSetStatusManyResponseSchema = z.object({ + /** Saved places whose status actually changed. */ + updated: z.number(), + /** Trip places that were found in at least one list (from-trip form only). */ + places: z.number().optional(), +}); +export type CollectionSetStatusManyResponse = z.infer; + /** Bulk-delete saved places. Plain z.number() (no .min/.int) mirrors the legacy * hand-rolled check the DTO ratchet replaced (same shape as placeBulkDeleteRequestSchema). */ export const collectionDeleteManyRequestSchema = z.object({ @@ -311,7 +336,15 @@ export type CollectionSaveResult = z.infer; /** Library-wide "is this place already saved anywhere I can see?" lookup (inspector indicator). */ export const collectionMembershipSchema = z.object({ saved: z.boolean(), - lists: z.array(z.object({ collection_id: z.number(), name: z.string(), place_id: z.number() })), + lists: z.array(z.object({ + collection_id: z.number(), + name: z.string(), + place_id: z.number(), + /** Per-list status, so the picker can show and change it without a second round trip (#1469). */ + status: collectionStatusSchema, + /** False for a list the viewer may read but not edit — its status pill stays read-only. */ + can_edit: z.boolean().default(true), + })), }); export type CollectionMembership = z.infer; diff --git a/shared/src/i18n/ar/collection.ts b/shared/src/i18n/ar/collection.ts index 6b5abfacc..4ec42b5e1 100644 --- a/shared/src/i18n/ar/collection.ts +++ b/shared/src/i18n/ar/collection.ts @@ -97,6 +97,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'الكل', 'collections.status.cycleHint': 'انقر للتغيير', + 'collections.markVisited': 'وضع علامة كمَزار', + 'collections.markVisitedAll': 'مَزار في كل القوائم', + 'collections.markVisitedSelection': 'وضع علامة كمَزار في قوائمك', + 'collections.markVisitedNone': 'لا يوجد أي من هذه الأماكن في أي قائمة', + 'collections.markedVisited': 'تم وضع علامة كمَزار', + 'collections.markedVisitedTrip': 'تم وضع علامة على {count} مكان كمَزار', + 'collections.copyToTrip': 'نسخ إلى رحلة', 'collections.copyToTripTitle': 'نسخ إلى رحلة', 'collections.copyN': 'نسخ {count} إلى رحلة', diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts index f37cc168b..45610115e 100644 --- a/shared/src/i18n/ar/journey.ts +++ b/shared/src/i18n/ar/journey.ts @@ -125,6 +125,8 @@ const journey: TranslationStrings = { 'journey.frontpage.places': 'places', // en-fallback 'journey.detail.syncedWithTrips': 'Synced with Trips', // en-fallback 'journey.detail.addEntry': 'Add Entry', // en-fallback + 'journey.detail.jumpToTop': 'العودة إلى الأعلى', + 'journey.detail.jumpToLast': 'الانتقال إلى آخر مدخل', 'journey.detail.newEntry': 'New Entry', // en-fallback 'journey.detail.editEntry': 'Edit Entry', // en-fallback 'journey.detail.noEntries': 'No entries yet', // en-fallback diff --git a/shared/src/i18n/br/collection.ts b/shared/src/i18n/br/collection.ts index 92b560ab1..f30f86c75 100644 --- a/shared/src/i18n/br/collection.ts +++ b/shared/src/i18n/br/collection.ts @@ -97,6 +97,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Todos', 'collections.status.cycleHint': 'toque para alterar', + 'collections.markVisited': 'Marcar como visitado', + 'collections.markVisitedAll': 'Visitado em todas', + 'collections.markVisitedSelection': 'Marcar como visitado nas suas listas', + 'collections.markVisitedNone': 'Nenhum desses lugares está salvo em uma lista', + 'collections.markedVisited': 'Marcado como visitado', + 'collections.markedVisitedTrip': '{count} lugares marcados como visitados', + 'collections.copyToTrip': 'Copiar para viagem', 'collections.copyToTripTitle': 'Copiar para viagem', 'collections.copyN': 'Copiar {count} para viagem', diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts index cc1c1dead..8a21149ca 100644 --- a/shared/src/i18n/br/journey.ts +++ b/shared/src/i18n/br/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Voltar à jornada', 'journey.detail.syncedWithTrips': 'Sincronizado com viagens', 'journey.detail.addEntry': 'Adicionar entrada', + 'journey.detail.jumpToTop': 'Voltar ao topo', + 'journey.detail.jumpToLast': 'Ir para a última entrada', 'journey.detail.newEntry': 'Nova entrada', 'journey.detail.editEntry': 'Editar entrada', 'journey.detail.noEntries': 'Nenhuma entrada ainda', diff --git a/shared/src/i18n/ca/collection.ts b/shared/src/i18n/ca/collection.ts index 54317730d..38c016505 100644 --- a/shared/src/i18n/ca/collection.ts +++ b/shared/src/i18n/ca/collection.ts @@ -92,6 +92,13 @@ const collection: TranslationStrings = { 'collections.status.visited': 'Visitat', 'collections.status.filterAll': 'Tot', 'collections.status.cycleHint': 'toca per canviar', + + 'collections.markVisited': 'Marca com a visitat', + 'collections.markVisitedAll': 'Visitat pertot', + 'collections.markVisitedSelection': 'Marca com a visitat a les teves llistes', + 'collections.markVisitedNone': 'Cap d\'aquests llocs no és desat en cap llista', + 'collections.markedVisited': 'Marcat com a visitat', + 'collections.markedVisitedTrip': '{count} llocs marcats com a visitats', 'collections.copyToTrip': 'Copiar al viatge', 'collections.copyToTripTitle': 'Copiar al viatge', 'collections.copyN': 'Copia {count} al viatge', diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts index a3008611c..6d0784808 100644 --- a/shared/src/i18n/ca/journey.ts +++ b/shared/src/i18n/ca/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Torna a la travesia', 'journey.detail.syncedWithTrips': 'Sincronitzat amb viatges', 'journey.detail.addEntry': 'Afegeix una entrada', + 'journey.detail.jumpToTop': 'Torna a dalt', + 'journey.detail.jumpToLast': 'Ves a l’última entrada', 'journey.detail.newEntry': 'Entrada nova', 'journey.detail.editEntry': "Edita l'entrada", 'journey.detail.noEntries': 'Encara no hi ha entrades', diff --git a/shared/src/i18n/cs/collection.ts b/shared/src/i18n/cs/collection.ts index 1912051c0..6e4efc1a5 100644 --- a/shared/src/i18n/cs/collection.ts +++ b/shared/src/i18n/cs/collection.ts @@ -96,6 +96,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Vše', 'collections.status.cycleHint': 'klepnutím změníte', + 'collections.markVisited': 'Označit jako navštívené', + 'collections.markVisitedAll': 'Navštíveno všude', + 'collections.markVisitedSelection': 'Označit jako navštívené v seznamech', + 'collections.markVisitedNone': 'Žádné z těchto míst není uloženo v seznamu', + 'collections.markedVisited': 'Označeno jako navštívené', + 'collections.markedVisitedTrip': '{count} míst označeno jako navštívená', + 'collections.copyToTrip': 'Kopírovat do výletu', 'collections.copyToTripTitle': 'Kopírovat do výletu', 'collections.copyN': 'Kopírovat {count} do výletu', diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts index d76f84097..6215cb622 100644 --- a/shared/src/i18n/cs/journey.ts +++ b/shared/src/i18n/cs/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Zpět na cestovní deník', 'journey.detail.syncedWithTrips': 'Synchronizováno s cestami', 'journey.detail.addEntry': 'Přidat záznam', + 'journey.detail.jumpToTop': 'Zpět nahoru', + 'journey.detail.jumpToLast': 'Přejít na poslední záznam', 'journey.detail.newEntry': 'Nový záznam', 'journey.detail.editEntry': 'Upravit záznam', 'journey.detail.noEntries': 'Zatím žádné záznamy', diff --git a/shared/src/i18n/de/collection.ts b/shared/src/i18n/de/collection.ts index baf898b49..864fda777 100644 --- a/shared/src/i18n/de/collection.ts +++ b/shared/src/i18n/de/collection.ts @@ -98,6 +98,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Alle', 'collections.status.cycleHint': 'tippen zum Ändern', + 'collections.markVisited': 'Als besucht markieren', + 'collections.markVisitedAll': 'Überall als besucht', + 'collections.markVisitedSelection': 'In deinen Listen als besucht markieren', + 'collections.markVisitedNone': 'Keiner dieser Orte ist in einer Liste gespeichert', + 'collections.markedVisited': 'Als besucht markiert', + 'collections.markedVisitedTrip': '{count} Orte als besucht markiert', + 'collections.copyToTrip': 'In Reise kopieren', 'collections.copyToTripTitle': 'In Reise kopieren', 'collections.copyN': '{count} in Reise kopieren', diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts index 6b4cad6a2..3319c5052 100644 --- a/shared/src/i18n/de/journey.ts +++ b/shared/src/i18n/de/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Zurück zur Journey', 'journey.detail.syncedWithTrips': 'Mit Trips synchronisiert', 'journey.detail.addEntry': 'Eintrag hinzufügen', + 'journey.detail.jumpToTop': 'Nach oben', + 'journey.detail.jumpToLast': 'Zum letzten Eintrag', 'journey.detail.newEntry': 'Neuer Eintrag', 'journey.detail.editEntry': 'Eintrag bearbeiten', 'journey.detail.noEntries': 'Noch keine Einträge', diff --git a/shared/src/i18n/en/collection.ts b/shared/src/i18n/en/collection.ts index 564eb8ba2..f4cc63657 100644 --- a/shared/src/i18n/en/collection.ts +++ b/shared/src/i18n/en/collection.ts @@ -97,6 +97,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'All', 'collections.status.cycleHint': 'tap to change', + 'collections.markVisited': 'Mark visited', + 'collections.markVisitedAll': 'Visited everywhere', + 'collections.markVisitedSelection': 'Mark visited in your lists', + 'collections.markVisitedNone': 'None of these places are saved in a list', + 'collections.markedVisited': 'Marked as visited', + 'collections.markedVisitedTrip': '{count} places marked as visited', + 'collections.copyToTrip': 'Copy to trip', 'collections.copyToTripTitle': 'Copy to trip', 'collections.copyN': 'Copy {count} to trip', diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts index 64cc10d7e..c96e82529 100644 --- a/shared/src/i18n/en/journey.ts +++ b/shared/src/i18n/en/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Back to Journey', 'journey.detail.syncedWithTrips': 'Synced with Trips', 'journey.detail.addEntry': 'Add Entry', + 'journey.detail.jumpToTop': 'Back to top', + 'journey.detail.jumpToLast': 'Jump to the last entry', 'journey.detail.newEntry': 'New Entry', 'journey.detail.editEntry': 'Edit Entry', 'journey.detail.noEntries': 'No entries yet', diff --git a/shared/src/i18n/es/collection.ts b/shared/src/i18n/es/collection.ts index a258fe95f..1be597344 100644 --- a/shared/src/i18n/es/collection.ts +++ b/shared/src/i18n/es/collection.ts @@ -97,6 +97,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Todos', 'collections.status.cycleHint': 'toca para cambiar', + 'collections.markVisited': 'Marcar como visitado', + 'collections.markVisitedAll': 'Visitado en todas', + 'collections.markVisitedSelection': 'Marcar como visitado en tus listas', + 'collections.markVisitedNone': 'Ninguno de estos lugares está guardado en una lista', + 'collections.markedVisited': 'Marcado como visitado', + 'collections.markedVisitedTrip': '{count} lugares marcados como visitados', + 'collections.copyToTrip': 'Copiar al viaje', 'collections.copyToTripTitle': 'Copiar al viaje', 'collections.copyN': 'Copiar {count} al viaje', diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts index 9f032a828..bb17806c8 100644 --- a/shared/src/i18n/es/journey.ts +++ b/shared/src/i18n/es/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Volver a la travesía', 'journey.detail.syncedWithTrips': 'Sincronizado con viajes', 'journey.detail.addEntry': 'Añadir entrada', + 'journey.detail.jumpToTop': 'Volver arriba', + 'journey.detail.jumpToLast': 'Ir a la última entrada', 'journey.detail.newEntry': 'Nueva entrada', 'journey.detail.editEntry': 'Editar entrada', 'journey.detail.noEntries': 'Aún no hay entradas', diff --git a/shared/src/i18n/fr/collection.ts b/shared/src/i18n/fr/collection.ts index 2d217b3e1..40067a47a 100644 --- a/shared/src/i18n/fr/collection.ts +++ b/shared/src/i18n/fr/collection.ts @@ -97,6 +97,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Tous', 'collections.status.cycleHint': 'touchez pour changer', + 'collections.markVisited': 'Marquer comme visité', + 'collections.markVisitedAll': 'Visité partout', + 'collections.markVisitedSelection': 'Marquer comme visité dans vos listes', + 'collections.markVisitedNone': 'Aucun de ces lieux n\'est enregistré dans une liste', + 'collections.markedVisited': 'Marqué comme visité', + 'collections.markedVisitedTrip': '{count} lieux marqués comme visités', + 'collections.copyToTrip': 'Copier vers un voyage', 'collections.copyToTripTitle': 'Copier vers un voyage', 'collections.copyN': 'Copier {count} vers un voyage', diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts index 30131c3ce..259f684b6 100644 --- a/shared/src/i18n/fr/journey.ts +++ b/shared/src/i18n/fr/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Retour au journal', 'journey.detail.syncedWithTrips': 'Synchronisé avec les voyages', 'journey.detail.addEntry': 'Ajouter une entrée', + 'journey.detail.jumpToTop': 'Revenir en haut', + 'journey.detail.jumpToLast': 'Aller à la dernière entrée', 'journey.detail.newEntry': 'Nouvelle entrée', 'journey.detail.editEntry': "Modifier l'entrée", 'journey.detail.noEntries': 'Aucune entrée pour le moment', diff --git a/shared/src/i18n/gr/collection.ts b/shared/src/i18n/gr/collection.ts index 85623436d..126b64de9 100644 --- a/shared/src/i18n/gr/collection.ts +++ b/shared/src/i18n/gr/collection.ts @@ -97,6 +97,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Όλα', 'collections.status.cycleHint': 'πάτησε για αλλαγή', + 'collections.markVisited': 'Σήμανση ως επισκεφθέν', + 'collections.markVisitedAll': 'Επισκεφθέν παντού', + 'collections.markVisitedSelection': 'Σήμανση ως επισκεφθέν στις λίστες σου', + 'collections.markVisitedNone': 'Καμία από αυτές τις τοποθεσίες δεν είναι αποθηκευμένη σε λίστα', + 'collections.markedVisited': 'Σημειώθηκε ως επισκεφθέν', + 'collections.markedVisitedTrip': '{count} τοποθεσίες σημειώθηκαν ως επισκεφθείσες', + 'collections.copyToTrip': 'Αντιγραφή σε ταξίδι', 'collections.copyToTripTitle': 'Αντιγραφή σε ταξίδι', 'collections.copyN': 'Αντιγραφή {count} σε ταξίδι', diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts index dcf197e1f..c176423e8 100644 --- a/shared/src/i18n/gr/journey.ts +++ b/shared/src/i18n/gr/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Πίσω στο Ταξίδι', 'journey.detail.syncedWithTrips': 'Συγχρονισμένο με Ταξίδια', 'journey.detail.addEntry': 'Προσθήκη Καταχώρησης', + 'journey.detail.jumpToTop': 'Επιστροφή στην κορυφή', + 'journey.detail.jumpToLast': 'Μετάβαση στην τελευταία καταχώρηση', 'journey.detail.newEntry': 'Νέα Καταχώρηση', 'journey.detail.editEntry': 'Επεξεργασία Καταχώρησης', 'journey.detail.noEntries': 'Δεν υπάρχουν καταχωρήσεις ακόμα', diff --git a/shared/src/i18n/hu/collection.ts b/shared/src/i18n/hu/collection.ts index ba70ddda7..9809e71ad 100644 --- a/shared/src/i18n/hu/collection.ts +++ b/shared/src/i18n/hu/collection.ts @@ -98,6 +98,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Összes', 'collections.status.cycleHint': 'koppints a módosításhoz', + 'collections.markVisited': 'Megjelölés látogatottként', + 'collections.markVisitedAll': 'Mindenhol látogatott', + 'collections.markVisitedSelection': 'Megjelölés látogatottként a listáidban', + 'collections.markVisitedNone': 'Ezek közül egy hely sincs listába mentve', + 'collections.markedVisited': 'Látogatottként megjelölve', + 'collections.markedVisitedTrip': '{count} hely megjelölve látogatottként', + 'collections.copyToTrip': 'Másolás utazásba', 'collections.copyToTripTitle': 'Másolás utazásba', 'collections.copyN': '{count} másolása utazásba', diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts index 73e14620f..c7501df23 100644 --- a/shared/src/i18n/hu/journey.ts +++ b/shared/src/i18n/hu/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Vissza az útinaplóhoz', 'journey.detail.syncedWithTrips': 'Szinkronizálva az utakkal', 'journey.detail.addEntry': 'Bejegyzés hozzáadása', + 'journey.detail.jumpToTop': 'Vissza a tetejére', + 'journey.detail.jumpToLast': 'Ugrás az utolsó bejegyzéshez', 'journey.detail.newEntry': 'Új bejegyzés', 'journey.detail.editEntry': 'Bejegyzés szerkesztése', 'journey.detail.noEntries': 'Még nincsenek bejegyzések', diff --git a/shared/src/i18n/id/collection.ts b/shared/src/i18n/id/collection.ts index 4556928ef..3775f83c4 100644 --- a/shared/src/i18n/id/collection.ts +++ b/shared/src/i18n/id/collection.ts @@ -97,6 +97,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Semua', 'collections.status.cycleHint': 'ketuk untuk mengubah', + 'collections.markVisited': 'Tandai dikunjungi', + 'collections.markVisitedAll': 'Dikunjungi di semua', + 'collections.markVisitedSelection': 'Tandai dikunjungi di daftar Anda', + 'collections.markVisitedNone': 'Tidak ada tempat ini yang tersimpan di daftar', + 'collections.markedVisited': 'Ditandai sebagai dikunjungi', + 'collections.markedVisitedTrip': '{count} tempat ditandai sebagai dikunjungi', + 'collections.copyToTrip': 'Salin ke perjalanan', 'collections.copyToTripTitle': 'Salin ke perjalanan', 'collections.copyN': 'Salin {count} ke perjalanan', diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts index 83d17d99e..76c0311f1 100644 --- a/shared/src/i18n/id/journey.ts +++ b/shared/src/i18n/id/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Kembali ke Journey', 'journey.detail.syncedWithTrips': 'Tersinkron dengan Perjalanan', 'journey.detail.addEntry': 'Tambah Entri', + 'journey.detail.jumpToTop': 'Kembali ke atas', + 'journey.detail.jumpToLast': 'Ke entri terakhir', 'journey.detail.newEntry': 'Entri Baru', 'journey.detail.editEntry': 'Edit Entri', 'journey.detail.noEntries': 'Belum ada entri', diff --git a/shared/src/i18n/it/collection.ts b/shared/src/i18n/it/collection.ts index da44fc061..013bc24f2 100644 --- a/shared/src/i18n/it/collection.ts +++ b/shared/src/i18n/it/collection.ts @@ -97,6 +97,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Tutti', 'collections.status.cycleHint': 'tocca per cambiare', + 'collections.markVisited': 'Segna come visitato', + 'collections.markVisitedAll': 'Visitato ovunque', + 'collections.markVisitedSelection': 'Segna come visitato nelle tue liste', + 'collections.markVisitedNone': 'Nessuno di questi luoghi è salvato in una lista', + 'collections.markedVisited': 'Segnato come visitato', + 'collections.markedVisitedTrip': '{count} luoghi segnati come visitati', + 'collections.copyToTrip': 'Copia nel viaggio', 'collections.copyToTripTitle': 'Copia nel viaggio', 'collections.copyN': 'Copia {count} nel viaggio', diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts index 0a8ddc337..5fb983923 100644 --- a/shared/src/i18n/it/journey.ts +++ b/shared/src/i18n/it/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Torna al diario', 'journey.detail.syncedWithTrips': 'Sincronizzato con i viaggi', 'journey.detail.addEntry': 'Aggiungi voce', + 'journey.detail.jumpToTop': 'Torna in alto', + 'journey.detail.jumpToLast': 'Vai all’ultima voce', 'journey.detail.newEntry': 'Nuova voce', 'journey.detail.editEntry': 'Modifica voce', 'journey.detail.noEntries': 'Nessuna voce ancora', diff --git a/shared/src/i18n/ja/collection.ts b/shared/src/i18n/ja/collection.ts index 0df044104..b6c14805d 100644 --- a/shared/src/i18n/ja/collection.ts +++ b/shared/src/i18n/ja/collection.ts @@ -96,6 +96,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'すべて', 'collections.status.cycleHint': 'タップで変更', + 'collections.markVisited': '訪問済みにする', + 'collections.markVisitedAll': 'すべてで訪問済み', + 'collections.markVisitedSelection': 'リストで訪問済みにする', + 'collections.markVisitedNone': 'これらの場所はどのリストにも保存されていません', + 'collections.markedVisited': '訪問済みにしました', + 'collections.markedVisitedTrip': '{count} 件の場所を訪問済みにしました', + 'collections.copyToTrip': '旅行にコピー', 'collections.copyToTripTitle': '旅行にコピー', 'collections.copyN': '{count}件を旅行にコピー', diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts index 0b4f25b81..c49f3880b 100644 --- a/shared/src/i18n/ja/journey.ts +++ b/shared/src/i18n/ja/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': '日記に戻る', 'journey.detail.syncedWithTrips': '旅行と同期済み', 'journey.detail.addEntry': 'エントリーを追加', + 'journey.detail.jumpToTop': '先頭へ戻る', + 'journey.detail.jumpToLast': '最後の記録へ', 'journey.detail.newEntry': '新しいエントリー', 'journey.detail.editEntry': 'エントリーを編集', 'journey.detail.noEntries': 'エントリーはまだありません', diff --git a/shared/src/i18n/ko/collection.ts b/shared/src/i18n/ko/collection.ts index a329cf8f4..933a47a45 100644 --- a/shared/src/i18n/ko/collection.ts +++ b/shared/src/i18n/ko/collection.ts @@ -96,6 +96,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': '전체', 'collections.status.cycleHint': '탭하여 변경', + 'collections.markVisited': '방문함으로 표시', + 'collections.markVisitedAll': '모든 목록에서 방문함', + 'collections.markVisitedSelection': '내 목록에서 방문함으로 표시', + 'collections.markVisitedNone': '이 장소들은 어떤 목록에도 저장되어 있지 않습니다', + 'collections.markedVisited': '방문함으로 표시했습니다', + 'collections.markedVisitedTrip': '장소 {count}곳을 방문함으로 표시했습니다', + 'collections.copyToTrip': '여행에 복사', 'collections.copyToTripTitle': '여행에 복사', 'collections.copyN': '{count}개를 여행에 복사', diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts index 4344b9483..159593ccc 100644 --- a/shared/src/i18n/ko/journey.ts +++ b/shared/src/i18n/ko/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Journey로 돌아가기', 'journey.detail.syncedWithTrips': '여행과 동기화됨', 'journey.detail.addEntry': '항목 추가', + 'journey.detail.jumpToTop': '맨 위로', + 'journey.detail.jumpToLast': '마지막 기록으로', 'journey.detail.newEntry': '새 항목', 'journey.detail.editEntry': '항목 편집', 'journey.detail.noEntries': '아직 항목이 없습니다', diff --git a/shared/src/i18n/nl/collection.ts b/shared/src/i18n/nl/collection.ts index 4c8ca2209..77cd4d7de 100644 --- a/shared/src/i18n/nl/collection.ts +++ b/shared/src/i18n/nl/collection.ts @@ -97,6 +97,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Alle', 'collections.status.cycleHint': 'tik om te wijzigen', + 'collections.markVisited': 'Markeer als bezocht', + 'collections.markVisitedAll': 'Overal bezocht', + 'collections.markVisitedSelection': 'Markeer als bezocht in je lijsten', + 'collections.markVisitedNone': 'Geen van deze plaatsen staat in een lijst', + 'collections.markedVisited': 'Gemarkeerd als bezocht', + 'collections.markedVisitedTrip': '{count} plaatsen gemarkeerd als bezocht', + 'collections.copyToTrip': 'Naar reis kopiëren', 'collections.copyToTripTitle': 'Naar reis kopiëren', 'collections.copyN': '{count} naar reis kopiëren', diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts index e0f20736b..30e5a7191 100644 --- a/shared/src/i18n/nl/journey.ts +++ b/shared/src/i18n/nl/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Terug naar reisverslag', 'journey.detail.syncedWithTrips': 'Gesynchroniseerd met reizen', 'journey.detail.addEntry': 'Vermelding toevoegen', + 'journey.detail.jumpToTop': 'Terug naar boven', + 'journey.detail.jumpToLast': 'Naar de laatste notitie', 'journey.detail.newEntry': 'Nieuwe vermelding', 'journey.detail.editEntry': 'Vermelding bewerken', 'journey.detail.noEntries': 'Nog geen vermeldingen', diff --git a/shared/src/i18n/pl/collection.ts b/shared/src/i18n/pl/collection.ts index 5aff76c1e..2cca3b0f3 100644 --- a/shared/src/i18n/pl/collection.ts +++ b/shared/src/i18n/pl/collection.ts @@ -97,6 +97,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Wszystkie', 'collections.status.cycleHint': 'dotknij, aby zmienić', + 'collections.markVisited': 'Oznacz jako odwiedzone', + 'collections.markVisitedAll': 'Odwiedzone wszędzie', + 'collections.markVisitedSelection': 'Oznacz jako odwiedzone na listach', + 'collections.markVisitedNone': 'Żadne z tych miejsc nie jest zapisane na liście', + 'collections.markedVisited': 'Oznaczono jako odwiedzone', + 'collections.markedVisitedTrip': 'Oznaczono {count} miejsc jako odwiedzone', + 'collections.copyToTrip': 'Kopiuj do podróży', 'collections.copyToTripTitle': 'Kopiuj do podróży', 'collections.copyN': 'Kopiuj {count} do podróży', diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts index 66310554d..08a20f07e 100644 --- a/shared/src/i18n/pl/journey.ts +++ b/shared/src/i18n/pl/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Powrót do dziennika podróży', 'journey.detail.syncedWithTrips': 'Zsynchronizowany z podróżami', 'journey.detail.addEntry': 'Dodaj wpis', + 'journey.detail.jumpToTop': 'Powrót na górę', + 'journey.detail.jumpToLast': 'Przejdź do ostatniego wpisu', 'journey.detail.newEntry': 'Nowy wpis', 'journey.detail.editEntry': 'Edytuj wpis', 'journey.detail.noEntries': 'Brak wpisów', diff --git a/shared/src/i18n/ru/collection.ts b/shared/src/i18n/ru/collection.ts index 6267176fe..db4d8f435 100644 --- a/shared/src/i18n/ru/collection.ts +++ b/shared/src/i18n/ru/collection.ts @@ -96,6 +96,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Все', 'collections.status.cycleHint': 'нажмите, чтобы изменить', + 'collections.markVisited': 'Отметить как посещённое', + 'collections.markVisitedAll': 'Посещено везде', + 'collections.markVisitedSelection': 'Отметить посещённым в списках', + 'collections.markVisitedNone': 'Ни одно из этих мест не сохранено в списке', + 'collections.markedVisited': 'Отмечено как посещённое', + 'collections.markedVisitedTrip': 'Отмечено мест как посещённые: {count}', + 'collections.copyToTrip': 'Скопировать в поездку', 'collections.copyToTripTitle': 'Скопировать в поездку', 'collections.copyN': 'Скопировать {count} в поездку', diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts index 3b616b486..188240988 100644 --- a/shared/src/i18n/ru/journey.ts +++ b/shared/src/i18n/ru/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Назад к путешествию', 'journey.detail.syncedWithTrips': 'Синхронизировано с поездками', 'journey.detail.addEntry': 'Добавить запись', + 'journey.detail.jumpToTop': 'Наверх', + 'journey.detail.jumpToLast': 'К последней записи', 'journey.detail.newEntry': 'Новая запись', 'journey.detail.editEntry': 'Редактировать запись', 'journey.detail.noEntries': 'Пока нет записей', diff --git a/shared/src/i18n/sv/collection.ts b/shared/src/i18n/sv/collection.ts index 291fa95a0..cc1ec3a96 100644 --- a/shared/src/i18n/sv/collection.ts +++ b/shared/src/i18n/sv/collection.ts @@ -96,6 +96,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Alla', 'collections.status.cycleHint': 'tryck för att ändra', + 'collections.markVisited': 'Markera som besökt', + 'collections.markVisitedAll': 'Besökt överallt', + 'collections.markVisitedSelection': 'Markera som besökt i dina listor', + 'collections.markVisitedNone': 'Ingen av dessa platser finns i en lista', + 'collections.markedVisited': 'Markerad som besökt', + 'collections.markedVisitedTrip': '{count} platser markerade som besökta', + 'collections.copyToTrip': 'Kopiera till resa', 'collections.copyToTripTitle': 'Kopiera till resa', 'collections.copyN': 'Kopiera {count} till resa', diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts index 60876cccb..253e4488d 100644 --- a/shared/src/i18n/sv/journey.ts +++ b/shared/src/i18n/sv/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Tillbaka till Journey', 'journey.detail.syncedWithTrips': 'Synkroniserat med resor', 'journey.detail.addEntry': 'Lägg till inlägg', + 'journey.detail.jumpToTop': 'Till toppen', + 'journey.detail.jumpToLast': 'Till senaste inlägget', 'journey.detail.newEntry': 'Ny inlägg', 'journey.detail.editEntry': 'Redigera inlägg', 'journey.detail.noEntries': 'Inga inlägg än så länge', diff --git a/shared/src/i18n/tr/collection.ts b/shared/src/i18n/tr/collection.ts index a90835737..344831ff5 100644 --- a/shared/src/i18n/tr/collection.ts +++ b/shared/src/i18n/tr/collection.ts @@ -97,6 +97,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Tümü', 'collections.status.cycleHint': 'değiştirmek için dokun', + 'collections.markVisited': 'Ziyaret edildi işaretle', + 'collections.markVisitedAll': 'Her yerde ziyaret edildi', + 'collections.markVisitedSelection': 'Listelerinde ziyaret edildi olarak işaretle', + 'collections.markVisitedNone': 'Bu yerlerin hiçbiri bir listede kayıtlı değil', + 'collections.markedVisited': 'Ziyaret edildi olarak işaretlendi', + 'collections.markedVisitedTrip': '{count} yer ziyaret edildi olarak işaretlendi', + 'collections.copyToTrip': 'Geziye kopyala', 'collections.copyToTripTitle': 'Geziye kopyala', 'collections.copyN': '{count} öğeyi geziye kopyala', diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts index 666ececfa..21fb9722d 100644 --- a/shared/src/i18n/tr/journey.ts +++ b/shared/src/i18n/tr/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': "Journey'e dön", 'journey.detail.syncedWithTrips': 'Seyahatlerle senkronize', 'journey.detail.addEntry': 'Kayıt Ekle', + 'journey.detail.jumpToTop': 'Başa dön', + 'journey.detail.jumpToLast': 'Son girdiye git', 'journey.detail.newEntry': 'Yeni Kayıt', 'journey.detail.editEntry': 'Kaydı Düzenle', 'journey.detail.noEntries': 'Henüz kayıt yok', diff --git a/shared/src/i18n/uk/collection.ts b/shared/src/i18n/uk/collection.ts index 19d85e07c..68d703213 100644 --- a/shared/src/i18n/uk/collection.ts +++ b/shared/src/i18n/uk/collection.ts @@ -96,6 +96,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Усі', 'collections.status.cycleHint': 'торкніться, щоб змінити', + 'collections.markVisited': 'Позначити як відвідане', + 'collections.markVisitedAll': 'Відвідано всюди', + 'collections.markVisitedSelection': 'Позначити відвіданим у списках', + 'collections.markVisitedNone': 'Жодне з цих місць не збережене у списку', + 'collections.markedVisited': 'Позначено як відвідане', + 'collections.markedVisitedTrip': 'Позначено місць як відвідані: {count}', + 'collections.copyToTrip': 'Скопіювати в подорож', 'collections.copyToTripTitle': 'Скопіювати в подорож', 'collections.copyN': 'Скопіювати {count} у подорож', diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts index 9e181584d..fd4c4fe5b 100644 --- a/shared/src/i18n/uk/journey.ts +++ b/shared/src/i18n/uk/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Назад до подорожі', 'journey.detail.syncedWithTrips': 'Синхронізовано з поїздками', 'journey.detail.addEntry': 'Додати запис', + 'journey.detail.jumpToTop': 'Нагору', + 'journey.detail.jumpToLast': 'До останнього запису', 'journey.detail.newEntry': 'Новий запис', 'journey.detail.editEntry': 'Редагувати запис', 'journey.detail.noEntries': 'Поки що немає записів', diff --git a/shared/src/i18n/vi/collection.ts b/shared/src/i18n/vi/collection.ts index 2330bbc0f..3f8a80624 100644 --- a/shared/src/i18n/vi/collection.ts +++ b/shared/src/i18n/vi/collection.ts @@ -97,6 +97,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': 'Tất cả', 'collections.status.cycleHint': 'chạm để thay đổi', + 'collections.markVisited': 'Đánh dấu đã đến', + 'collections.markVisitedAll': 'Đã đến ở mọi danh sách', + 'collections.markVisitedSelection': 'Đánh dấu đã đến trong danh sách của bạn', + 'collections.markVisitedNone': 'Không có địa điểm nào trong số này được lưu vào danh sách', + 'collections.markedVisited': 'Đã đánh dấu là đã đến', + 'collections.markedVisitedTrip': 'Đã đánh dấu {count} địa điểm là đã đến', + 'collections.copyToTrip': 'Sao chép vào chuyến đi', 'collections.copyToTripTitle': 'Sao chép vào chuyến đi', 'collections.copyN': 'Sao chép {count} vào chuyến đi', diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts index 20eaaf909..3864a52ef 100644 --- a/shared/src/i18n/vi/journey.ts +++ b/shared/src/i18n/vi/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': 'Quay lại Hành trình', 'journey.detail.syncedWithTrips': 'Đã đồng bộ hóa với Chuyến đi', 'journey.detail.addEntry': 'Thêm mục nhập', + 'journey.detail.jumpToTop': 'Lên đầu trang', + 'journey.detail.jumpToLast': 'Đến mục cuối cùng', 'journey.detail.newEntry': 'Mục mới', 'journey.detail.editEntry': 'Chỉnh sửa mục nhập', 'journey.detail.noEntries': 'Chưa có mục nào', diff --git a/shared/src/i18n/zh-TW/collection.ts b/shared/src/i18n/zh-TW/collection.ts index b3f49ee34..4027978d2 100644 --- a/shared/src/i18n/zh-TW/collection.ts +++ b/shared/src/i18n/zh-TW/collection.ts @@ -96,6 +96,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': '全部', 'collections.status.cycleHint': '點按切換', + 'collections.markVisited': '標記為已造訪', + 'collections.markVisitedAll': '在所有清單中標記', + 'collections.markVisitedSelection': '在清單中標記為已造訪', + 'collections.markVisitedNone': '這些地點都沒有儲存在任何清單中', + 'collections.markedVisited': '已標記為造訪', + 'collections.markedVisitedTrip': '已將 {count} 個地點標記為造訪', + 'collections.copyToTrip': '複製到行程', 'collections.copyToTripTitle': '複製到行程', 'collections.copyN': '將 {count} 個複製到行程', diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts index 5622a99a5..e9dab1573 100644 --- a/shared/src/i18n/zh-TW/journey.ts +++ b/shared/src/i18n/zh-TW/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': '返回旅程', 'journey.detail.syncedWithTrips': '已與旅行同步', 'journey.detail.addEntry': '新增條目', + 'journey.detail.jumpToTop': '回到頂部', + 'journey.detail.jumpToLast': '跳到最後一則', 'journey.detail.newEntry': '新建條目', 'journey.detail.editEntry': '編輯條目', 'journey.detail.noEntries': '還沒有條目', diff --git a/shared/src/i18n/zh/collection.ts b/shared/src/i18n/zh/collection.ts index 0d5b8dae6..7a8fbfb62 100644 --- a/shared/src/i18n/zh/collection.ts +++ b/shared/src/i18n/zh/collection.ts @@ -96,6 +96,13 @@ const collection: TranslationStrings = { 'collections.status.filterAll': '全部', 'collections.status.cycleHint': '点按切换', + 'collections.markVisited': '标记为已去过', + 'collections.markVisitedAll': '在所有清单中标记', + 'collections.markVisitedSelection': '在清单中标记为已去过', + 'collections.markVisitedNone': '这些地点都没有保存在任何清单中', + 'collections.markedVisited': '已标记为去过', + 'collections.markedVisitedTrip': '已将 {count} 个地点标记为去过', + 'collections.copyToTrip': '复制到行程', 'collections.copyToTripTitle': '复制到行程', 'collections.copyN': '将 {count} 个复制到行程', diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts index 14b5bef8f..fadefc2ce 100644 --- a/shared/src/i18n/zh/journey.ts +++ b/shared/src/i18n/zh/journey.ts @@ -76,6 +76,8 @@ const journey: TranslationStrings = { 'journey.detail.backToJourney': '返回旅程', 'journey.detail.syncedWithTrips': '已与旅行同步', 'journey.detail.addEntry': '添加条目', + 'journey.detail.jumpToTop': '回到顶部', + 'journey.detail.jumpToLast': '跳到最后一条', 'journey.detail.newEntry': '新建条目', 'journey.detail.editEntry': '编辑条目', 'journey.detail.noEntries': '还没有条目', diff --git a/wiki/Calendar-Feeds.md b/wiki/Calendar-Feeds.md index d46cddc78..7e9bdcf06 100644 --- a/wiki/Calendar-Feeds.md +++ b/wiki/Calendar-Feeds.md @@ -59,6 +59,8 @@ The feed carries the same events as the ICS export: - **Timed day assignments** — one event per place that has a time, using the place name as the title, its address as the location, and its notes in the description. Times are anchored to the place's own time zone. - **A per-day summary event** — an all-day event for each day that has untimed places or notes, titled with the day title (or *Day N*), listing those places and notes in the description. - **Reservations** — hotels, restaurants, and transport. Flights and other transport take their start and end from the departure and arrival endpoints, each in its own time zone. Reservations with no placeable date are skipped. +- **Accommodations** — an all-day event covering every night of the stay, from the arrival day to the departure day, so a hotel sits above those days rather than appearing once on the day you check in. The dates come from the trip days the stay is attached to, so reordering days moves the event with them. +- **Check-in and check-out** — separate timed events on the arrival and departure days whenever the stay records those times. If you entered a check-in window, its end becomes the event's end time. Feeds are served with cache headers that tell clients not to cache, plus an hourly refresh hint (`REFRESH-INTERVAL` / `X-PUBLISHED-TTL` of one hour). Most calendar apps treat that as a suggestion — Google in particular refreshes on its own schedule, often much slower — so an edit may take a while to show up. diff --git a/wiki/Collections.md b/wiki/Collections.md index 6cec3f155..005dc5911 100644 --- a/wiki/Collections.md +++ b/wiki/Collections.md @@ -34,6 +34,15 @@ Every saved place carries a status you can cycle with one tap: Status is a Collections concept and is not carried into a trip when you copy a place. +### Setting it from a trip + +You usually find out you have been somewhere while looking at the trip, not while browsing the library, so the status can be set from there too: + +- The **Save to list** dialog on a trip place shows every list that already holds it, each with its own status pill. Tap a pill to cycle that list's status, or use **Visited everywhere** to mark all of them at once. +- The places panel has a **mark visited** action in its selection bar (and in the bulk toolbar on phones). Select the places — *Select all* covers the whole trip — and every saved copy of them is marked visited. + +Matching is by the place's provider id, its coordinates, or the link a place saved out of that trip already carries, so a copy you renamed inside a list is still found. Lists shared with you read-only are left alone. + ## Categories Places can be assigned a **category** from the same admin-defined set used across TREK (see [Admin: Categories](Admin-Categories)). Category colours and icons show on the place avatar, the place detail and the list rows, and you can filter a list by category. diff --git a/wiki/Journey-Journal.md b/wiki/Journey-Journal.md index 50aa16c5a..f3c704457 100644 --- a/wiki/Journey-Journal.md +++ b/wiki/Journey-Journal.md @@ -41,6 +41,10 @@ Each entry corresponds to a day in your journey. The entry editor provides: - **Location** — pin the entry to a map location. - **Time** — optionally record a time of day for the entry. +### Getting around a long journal + +Everything that adds to a journal sits at the top of the page — the **Add Entry** button, the gallery upload — while what you were last reading sits at the bottom. Two small buttons ride along the left edge of the timeline once there is more than a screenful to travel: one jumps back to the top, the other to the last entry. Each appears only when there is somewhere to go in that direction. + ### External photos The entry editor includes an **External photos** tab for connected Immich and Synology Photos libraries. It searches the selected calendar day automatically. When the entry has a pinned location, photos with GPS metadata are shown nearest to that location first. Photos without GPS, and photos taken farther away on the same day, remain available below the nearby results; no distance cutoff is applied. diff --git a/wiki/MCP-Setup.md b/wiki/MCP-Setup.md index 94ee08fc2..95b936034 100644 --- a/wiki/MCP-Setup.md +++ b/wiki/MCP-Setup.md @@ -88,7 +88,7 @@ Each user can have up to **10 OAuth clients**. Use this when your AI agent or automation script needs to authenticate silently without any browser interaction. Instead of going through an OAuth consent flow, the client exchanges a `client_id` and `client_secret` directly for an access token ([RFC 6749 §4.4 — Client Credentials grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)). -**Why this exists:** browser-based OAuth flows break when an AI agent runs unattended. The agent may fire multiple concurrent token refreshes, causing replay detection to invalidate the session and open browser windows. Machine clients sidestep this entirely — there is no refresh token and no rotation race. +**Why this exists:** browser-based OAuth flows are an awkward fit for an agent running unattended. Two sessions sharing one refresh token used to be read as a replay, which revoked the whole chain and popped a login window; TREK now allows a short grace period on a just-rotated token, so a concurrent refresh no longer ends the session. Machine clients still sidestep the question entirely — there is no refresh token and no rotation at all. **How it works:** the token acts as its owner (the user who created the client), scoped to the permissions chosen at creation. All TREK permission checks still apply — the AI agent can only access what you can access, narrowed further to the selected scopes. diff --git a/wiki/Packing-Lists.md b/wiki/Packing-Lists.md index 67ebc22f9..a53527675 100644 --- a/wiki/Packing-Lists.md +++ b/wiki/Packing-Lists.md @@ -48,6 +48,8 @@ Each item row contains: Hovering over an item reveals a **category picker** (colored dot), a **rename** button (pencil icon), and a **delete** button. Add new items using the inline "add item" row at the bottom of each category. +On phones the row keeps the name and reduces everything else: sharing shows as an icon rather than a sentence, a weight nobody entered prints nothing, and edit/delete sit behind the **⋯** button at the end of the row while the list is in edit mode. + ## Sharing packing items Every packing item has a sharing tier that controls who sees it and who is bringing it. By default everything sits in the shared group pool, exactly as before — the tiers are opt-in per item. diff --git a/wiki/Places-and-Search.md b/wiki/Places-and-Search.md index 91f92885c..87016c791 100644 --- a/wiki/Places-and-Search.md +++ b/wiki/Places-and-Search.md @@ -91,6 +91,8 @@ Drag a `.gpx`, `.kml`, or `.kmz` file onto the Places sidebar to import all wayp Imported tracks each get their own line colour so multiple routes stay apart on the map; you can override it per track from the place details. See [Map Features](Map-Features) for the details. +Importing the same list again does not duplicate what is already in the trip. A place is recognised by the provider id it was imported with (Google place id, Google feature id, or OSM id) before its name or its coordinates are considered, so renaming a place in TREK — or moving its pin — does not make it come back as a second copy on the next import. + > **Admin:** Google Maps API key is set in [User-Settings](User-Settings). Without it, OSM search is used automatically. **See also:** [Day-Plans-and-Notes](Day-Plans-and-Notes) · [Map-Features](Map-Features) · [Tags-and-Categories](Tags-and-Categories) From 3c5e406229aa01bafe205cc3b0f09f5416fc42b3 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:37:12 +0200 Subject: [PATCH 03/20] feat(places): link an expense to a place, the way bookings already do (#1917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(places): link an expense to a place, the way bookings already do Recording what a museum ticket or a guided tour cost meant leaving the place, opening the Costs tab and typing it in again — while a booking has had a Costs block in its own form all along. The place form now carries that block. Same component, same flow: with nothing linked it offers Create expense, which saves the place and then opens the Costs editor for a new expense pointing at it; once linked it shows the expense with edit and remove. budget_items gains place_id beside the reservation_id it already had, with the same nullable FK and the same ON DELETE SET NULL. Desktop and phone both, and the mobile editor carries the link through its own save path rather than the desktop one. The expense belongs to the place, not to a day assignment, so a place sitting on five days is not five tickets. Hanging it off the assignment would have meant adding a place to three days quietly creating three expenses and removing a day quietly deleting one. Deleting a place takes its expense with it in the same transaction and announces it, on every path that deletes a place: REST, both MCP tools and the plugin RPC. Closes #1298 * test(places): teach the RPC stubs about linkedExpenseIds The delete paths read the linked expense before the place is gone, so the two hand-built PlacesService stubs threw on the new method instead of reaching the hook, the remove and the broadcast — four failures out of one missing line. --- .../src/components/Budget/CostsPanel.test.tsx | 20 ++++ client/src/components/Budget/CostsPanel.tsx | 3 + .../Planner/BookingCostsSection.tsx | 28 ++++-- .../Planner/BookingCostsSection.types.ts | 9 +- .../Planner/PlaceFormModal.test.tsx | 92 ++++++++++++++++++- .../src/components/Planner/PlaceFormModal.tsx | 61 +++++++++++- .../mobile/screens/trip/sheets/MCostSheet.tsx | 1 + .../screens/trip/sheets/MPlaceEditSheet.tsx | 37 +++++++- .../screens/trip/sheets/MTripSheets.tsx | 2 +- client/src/pages/TripPlannerPage.tsx | 2 +- .../src/pages/tripPlanner/useTripPlanner.ts | 4 + .../unit/mobile/trip/MCostSheet.test.tsx | 15 ++- .../unit/mobile/trip/MPlaceEditSheet.test.tsx | 56 ++++++++++- server/src/db/migrations.ts | 12 +++ server/src/nest/budget/budget.service.ts | 4 +- server/src/nest/places/places.controller.ts | 12 ++- server/src/nest/places/places.mcp.ts | 9 +- server/src/nest/places/places.rpc.ts | 3 + server/src/nest/places/places.service.ts | 24 ++++- server/src/types.ts | 3 + server/tests/e2e/places.e2e.test.ts | 14 +++ .../integration/leg-mode-incoming.test.ts | 23 +++-- server/tests/unit/mcp/tools-places.test.ts | 15 +++ .../tests/unit/nest/budget.service.db.test.ts | 25 +++++ .../tests/unit/nest/places.controller.test.ts | 14 +++ server/tests/unit/nest/places.service.test.ts | 45 +++++++++ server/tests/unit/places/places.rpc.test.ts | 1 + .../tests/unit/plugins/rpc-fallbacks.test.ts | 2 +- shared/src/budget/budget.schema.ts | 6 ++ shared/src/i18n/ar/places.ts | 1 + shared/src/i18n/br/places.ts | 1 + shared/src/i18n/ca/places.ts | 1 + shared/src/i18n/cs/places.ts | 1 + shared/src/i18n/de/places.ts | 1 + shared/src/i18n/en/places.ts | 1 + shared/src/i18n/es/places.ts | 1 + shared/src/i18n/fr/places.ts | 1 + shared/src/i18n/gr/places.ts | 1 + shared/src/i18n/hu/places.ts | 1 + shared/src/i18n/id/places.ts | 1 + shared/src/i18n/it/places.ts | 1 + shared/src/i18n/ja/places.ts | 1 + shared/src/i18n/ko/places.ts | 1 + shared/src/i18n/nl/places.ts | 1 + shared/src/i18n/pl/places.ts | 1 + shared/src/i18n/ru/places.ts | 1 + shared/src/i18n/sv/places.ts | 1 + shared/src/i18n/tr/places.ts | 1 + shared/src/i18n/uk/places.ts | 1 + shared/src/i18n/vi/places.ts | 1 + shared/src/i18n/zh-TW/places.ts | 1 + shared/src/i18n/zh/places.ts | 1 + wiki/Budget-Tracking.md | 6 ++ wiki/Places-and-Search.md | 8 ++ wiki/Reservations-and-Bookings.md | 2 +- 55 files changed, 540 insertions(+), 41 deletions(-) diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 6dd9ec25c..037d62a37 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -1393,6 +1393,26 @@ describe('CostsPanel — expense modal', () => { expect(posted).toMatchObject({ name: 'Hotel Astoria', category: 'accommodation', total_price: 240, reservation_id: 12 }) expect(onSaved).toHaveBeenCalled() }) + + it('FE-W5COSTS-038b: a prefill from a place links the expense to that place (#1298)', async () => { + const user = userEvent.setup() + let posted: Record | null = null + server.use(http.post('/api/trips/1/budget', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ item: dinner() }) + })) + render( + {}} onSaved={vi.fn()} /> + ) + + await user.click(screen.getByRole('button', { name: 'Add expense' })) + + await waitFor(() => expect(posted).toBeTruthy()); + expect(posted).toMatchObject({ name: 'Louvre', category: 'activities', total_price: 34, place_id: 7 }) + expect(posted).not.toHaveProperty('reservation_id') + }) }) describe('CostsPanel — remaining paths', () => { diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 4c10528cd..2a210162a 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -1000,6 +1000,8 @@ export interface ExpensePrefill { category?: string amount?: number reservationId?: number + /** Set when the expense is being created from a place (#1298). */ + placeId?: number } export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: { @@ -1252,6 +1254,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo note: note.trim() || null, ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null, ...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}), + ...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}), } try { if (editing) await updateBudgetItem(tripId, editing.id, data) diff --git a/client/src/components/Planner/BookingCostsSection.tsx b/client/src/components/Planner/BookingCostsSection.tsx index 89851c52f..b8a491985 100644 --- a/client/src/components/Planner/BookingCostsSection.tsx +++ b/client/src/components/Planner/BookingCostsSection.tsx @@ -7,13 +7,21 @@ import { catMeta } from '../Budget/costsCategories' import type { BudgetItem } from '../../types' /** - * The Costs block inside a booking modal. Replaces the old inline price + budget - * category fields: when no expense is linked yet it offers a "create expense" - * button (the modal saves the booking first, then opens the full Costs editor); - * once linked it shows the expense with edit / remove actions. + * The Costs block inside a booking modal — and, since #1298, inside the place + * form, which links its expense the same way. Replaces the old inline price + + * budget category fields: when no expense is linked yet it offers a "create + * expense" button (the modal saves its own record first, then opens the full + * Costs editor); once linked it shows the expense with edit / remove actions. + * + * Exactly one of reservationId / placeId is set — they are the two sides of the + * same link, and the block behaves identically on both. */ -export function BookingCostsSection({ reservationId, pendingExpense, onCreate, onEdit, onRemove }: { +export function BookingCostsSection({ reservationId, placeId = null, hintKey = 'reservations.createExpenseHint', pendingExpense, onCreate, onEdit, onRemove }: { reservationId: number | null + /** Set instead of reservationId when the block sits in the place form (#1298). */ + placeId?: number | null + /** What gets saved before the editor opens — "the booking" or "the place". */ + hintKey?: string /** A cost parsed from an import that will be linked on save — previewed before the booking exists. */ pendingExpense?: { total_price: number; currency?: string | null; category: string } | null onCreate: () => void @@ -25,7 +33,11 @@ export function BookingCostsSection({ reservationId, pendingExpense, onCreate, o const trip = useTripStore(s => s.trip) const displayCurrency = useSettingsStore(s => s.settings.default_currency) const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() - const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null + const linked = reservationId + ? budgetItems.find(i => i.reservation_id === reservationId) + : placeId + ? budgetItems.find(i => i.place_id === placeId) + : null const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]' @@ -40,7 +52,7 @@ export function BookingCostsSection({ reservationId, pendingExpense, onCreate, o
{t(meta.labelKey)}
-
{t('reservations.createExpenseHint')}
+
{t(hintKey)}
{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)} @@ -76,7 +88,7 @@ export function BookingCostsSection({ reservationId, pendingExpense, onCreate, o style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}> {t('reservations.createExpense')} -
{t('reservations.createExpenseHint')}
+
{t(hintKey)}
) } diff --git a/client/src/components/Planner/BookingCostsSection.types.ts b/client/src/components/Planner/BookingCostsSection.types.ts index a4805e4a8..5e100cdb6 100644 --- a/client/src/components/Planner/BookingCostsSection.types.ts +++ b/client/src/components/Planner/BookingCostsSection.types.ts @@ -1,11 +1,12 @@ import type { BudgetItem } from '../../types' /** - * A request from a booking modal to open the Costs expense editor — either to - * edit the already-linked expense, or to create a new one prefilled from the - * booking (the modal saves the booking first so `reservationId` is known). + * A request from a booking modal — or from the place form (#1298) — to open the + * Costs expense editor: either to edit the already-linked expense, or to create + * a new one prefilled from the record. The modal saves itself first, so the + * `reservationId` / `placeId` it names already exists. */ export interface BookingExpenseRequest { editItem?: BudgetItem - prefill?: { reservationId?: number; name?: string; category?: string; amount?: number } + prefill?: { reservationId?: number; placeId?: number; name?: string; category?: string; amount?: number } } diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index 04e225f39..063614aca 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-062 +// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -072 import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -1467,4 +1467,94 @@ describe('PlaceFormModal remaining branches', () => { expect(websiteInput().value).toBe('https://stored.example'); }); }); + + // ── Linked expense (#1298) — the same block bookings have ────────────────── + + describe('Costs section', () => { + const withBudget = () => seedStore(useAddonStore, { + addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], + loaded: true, + }); + + it('FE-PLANNER-PLACEFORM-068: the block only appears while the Budget addon is on', () => { + const { unmount } = render(); + expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument(); + unmount(); + + withBudget(); + render(); + expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-069: creating an expense saves the place first, then opens the editor', async () => { + withBudget(); + const onSave = vi.fn().mockResolvedValue({ id: 42 }); + const onOpenExpense = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } }); + fireEvent.click(screen.getByRole('button', { name: /Create expense/i })); + + await waitFor(() => expect(onOpenExpense).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalled(); + expect(onSave.mock.invocationCallOrder[0]).toBeLessThan(onOpenExpense.mock.invocationCallOrder[0]); + expect(onOpenExpense).toHaveBeenCalledWith({ + prefill: { placeId: 42, name: 'Louvre', category: 'activities' }, + }); + }); + + it('FE-PLANNER-PLACEFORM-070: a save that yields no id opens no editor', async () => { + withBudget(); + const onSave = vi.fn().mockResolvedValue(undefined); + const onOpenExpense = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } }); + fireEvent.click(screen.getByRole('button', { name: /Create expense/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onOpenExpense).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLACEFORM-070b: a nameless click drops the intent instead of arming the next save', async () => { + withBudget(); + const onSave = vi.fn().mockResolvedValue({ id: 42 }); + const onOpenExpense = vi.fn(); + render(); + + // No name yet — the save is refused and the intent must not survive it. + fireEvent.click(screen.getByRole('button', { name: /Create expense/i })); + await waitFor(() => expect(onSave).not.toHaveBeenCalled()); + + fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } }); + fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onOpenExpense).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLACEFORM-071: an already-linked expense is shown with its amount instead of the button', () => { + withBudget(); + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + budgetItems: [{ id: 8, trip_id: 1, name: 'Louvre tickets', total_price: 34, category: 'activities', place_id: 7 }], + }); + render(); + + expect(screen.getByText('Louvre tickets')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-072: an expense linked to another place is not claimed', () => { + withBudget(); + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + budgetItems: [{ id: 8, trip_id: 1, name: 'Orsay tickets', total_price: 16, category: 'activities', place_id: 99 }], + }); + render(); + + expect(screen.queryByText('Orsay tickets')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 1927f0c84..1648f8aaa 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -16,7 +16,9 @@ import { useTranslation } from '../../i18n' import CustomTimePicker from '../shared/CustomTimePicker' import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers' import { getApiErrorMessage } from '../../utils/apiError' -import type { Place, Category, Assignment } from '../../types' +import { BookingCostsSection } from './BookingCostsSection' +import type { BookingExpenseRequest } from './BookingCostsSection.types' +import type { Place, Category, Assignment, BudgetItem } from '../../types' import { NumericInput } from '../shared/NumericInput' // The submit payload mirrors the form, but lat/lng are parsed to numbers and @@ -31,7 +33,7 @@ export interface PlaceSubmitData extends Omit void - onSave: (data: PlaceSubmitData, files?: File[]) => Promise | void + onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void place: Place | null prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null tripId: number @@ -43,6 +45,9 @@ interface PlaceFormModalProps { * picker column when the Collections addon is enabled. Sourced from the trip * planner's matchMedia('(max-width:767px)'). */ isMobile?: boolean + /** Opens the Costs editor for this place's linked expense (#1298) — the same + * seam the booking and transport modals use. */ + onOpenExpense?: (req: BookingExpenseRequest) => void } @@ -78,6 +83,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const { isOpen, onClose, onSave, place, prefillCoords, tripId, categories, onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false, + onOpenExpense, } = props const [form, setForm] = useState(DEFAULT_FORM) const [mapsSearch, setMapsSearch] = useState('') @@ -108,6 +114,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const tripObj = useTripStore((s) => s.trip) const canUploadFiles = can('file_upload', tripObj) const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections')) + const isBudgetEnabled = useAddonStore((s) => s.isEnabled('budget')) + const deleteBudgetItem = useTripStore((s) => s.deleteBudgetItem) + // Set right before submit when the user clicked create/edit expense — the + // place has to exist before an expense can point at it (see ReservationModal). + const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null) useEffect(() => { if (place) { @@ -430,9 +441,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const hasTimeError = place && form.place_time && form.end_time && form.place_time.length >= 5 && form.end_time.length >= 5 && form.end_time <= form.place_time - const handleSubmit = async (e) => { - e.preventDefault() + const handleSubmit = async (e?: { preventDefault?: () => void }) => { + e?.preventDefault?.() if (!form.name.trim()) { + // Nothing was saved, so an expense intent from a previous click is stale — + // otherwise the next plain Save would open a Costs editor out of nowhere. + expenseIntentRef.current = null toast.error(t('places.nameRequired')) return } @@ -449,13 +463,23 @@ function usePlaceFormModal(props: PlaceFormModalProps) { } setIsSaving(true) try { - await onSave({ + const saved = await onSave({ ...form, lat: form.lat ? parseFloat(form.lat) : null, lng: form.lng ? parseFloat(form.lng) : null, category_id: form.category_id || null, _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, }) + // Open the Costs editor for the saved place when the user asked to + // create/edit its linked expense — gated on an id, so a create that the + // server refused never opens an editor pointing at nothing (#1298). + const intent = expenseIntentRef.current + expenseIntentRef.current = null + const savedId = (saved && 'id' in saved ? saved.id : null) ?? place?.id ?? null + if (intent && onOpenExpense && savedId) { + if (intent.editItem) onOpenExpense({ editItem: intent.editItem }) + else onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } }) + } onClose() } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('places.saveError')) @@ -464,6 +488,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) { } } + const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; void handleSubmit() } + const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; void handleSubmit() } + const handleRemoveExpense = async (item: BudgetItem) => { + try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) } + } + return { isOpen, onClose, @@ -527,6 +557,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) { handleSubmit, duplicateWarning, detailsSelection, + isBudgetEnabled, + handleCreateExpense, + handleEditExpense, + handleRemoveExpense, } } @@ -593,6 +627,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { handleSubmit, duplicateWarning, detailsSelection, + isBudgetEnabled, + handleCreateExpense, + handleEditExpense, + handleRemoveExpense, } = S // Desktop + Collections addon → the saved-place picker on the right. Mobile // always keeps the original single-column form untouched. @@ -917,6 +955,19 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { )} + {/* Costs — create / view the expense linked to this place (#1298). + Same block, same flow as a booking: save first, then the editor. */} + {isBudgetEnabled && ( + + )} + {twoColumn && ( diff --git a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx index aa4acbbe8..fc57e938d 100644 --- a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx @@ -258,6 +258,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, note: note.trim() || null, ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null, ...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}), + ...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}), } try { if (editing) await updateBudgetItem(tripId, editing.id, data) diff --git a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx index fab3821aa..3cb27e686 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from 'react' -import { MapPin } from 'lucide-react' +import { MapPin, Plus } from 'lucide-react' import MSheet from '../../../components/MSheet' import { DEFAULT_FORM, @@ -8,6 +8,8 @@ import { type ResultField, } from '../../../../components/Planner/PlaceFormModal.helpers' import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome' +import { useAddonStore } from '../../../../store/addonStore' +import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types' import PlPlaceSearch, { type PlSearchPick } from './PlPlaceSearch' import PlCategoryPicker from './PlCategoryPicker' import PlTimeFields from './PlTimeFields' @@ -17,6 +19,8 @@ import type { TripPlanner } from '../MTripShell' export interface MPlaceEditSheetProps { planner: TripPlanner + /** Opens the Costs editor for this place's linked expense (#1298). */ + onOpenExpense: (req: BookingExpenseRequest) => void } // #1152: same duplicate heuristic as the desktop form — shared Google Place ID, @@ -51,7 +55,7 @@ function findDuplicateName( * planner.handleSavePlace, which owns the assignment-time split, pending-file * upload and undo. */ -export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) { +export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSheetProps) { const { t, toast, places, assignments, canUploadFiles, showPlaceForm, setShowPlaceForm, @@ -68,6 +72,10 @@ export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) { const [isSaving, setIsSaving] = useState(false) const [resolvingPick, setResolvingPick] = useState(false) const [duplicateWarning, setDuplicateWarning] = useState(null) + const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) + // Set right before submit: the place has to exist before an expense can point + // at it, exactly like MReservationSheet does it. + const expenseIntentRef = useRef(false) const [deleteArmed, setDeleteArmed] = useState(false) // Open-time snapshot: closing clears the planner flags immediately, but the // sheet still shows through its exit animation — render off the snapshot so @@ -221,15 +229,21 @@ export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) { return } } + const withExpense = expenseIntentRef.current + expenseIntentRef.current = false setIsSaving(true) try { - await handleSavePlace({ + const saved = await handleSavePlace({ ...form, lat: form.lat ? parseFloat(form.lat) : null, lng: form.lng ? parseFloat(form.lng) : null, category_id: form.category_id || null, _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, }) + const savedId = saved?.id ?? sheetPlace?.id ?? null + if (withExpense && savedId) { + onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } }) + } handleClose() } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('places.saveError')) @@ -360,6 +374,23 @@ export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) { onRemove={idx => setPendingFiles(prev => prev.filter((_, i) => i !== idx))} /> )} + + {/* COSTS — same block, same flow as the booking sheet (#1298) */} + {isBudgetEnabled && ( + <> + {t('reservations.costsLabel')} + +
{t('places.createExpenseHint')}
+ + )} {/* ── Planner-flag editors (also serve ?create= and the import review) ── */} - + diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 200b6ef67..814213cce 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -789,7 +789,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { )} - { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} /> + { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} /> setShowTripForm(false)} diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index b0d3f13bf..4e920677d 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -545,6 +545,7 @@ export function useTripPlanner() { } } toast.success(t('trip.toast.placeUpdated')) + return { id: editingPlace.id } } else { const place = await tripActions.addPlace(tripId, data) if (pendingFiles?.length > 0 && place?.id) { @@ -562,6 +563,9 @@ export function useTripPlanner() { await tripActions.deletePlace(tripId, capturedId) }) } + // Handed back so the form can link an expense to a place that did not + // exist a moment ago (#1298), the same way the booking modals work. + return place?.id ? { id: place.id } : undefined } }, [editingPlace, editingAssignmentId, tripId, toast, pushUndo]) diff --git a/client/tests/unit/mobile/trip/MCostSheet.test.tsx b/client/tests/unit/mobile/trip/MCostSheet.test.tsx index c9f4d1a33..3689e1b13 100644 --- a/client/tests/unit/mobile/trip/MCostSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MCostSheet.test.tsx @@ -33,7 +33,7 @@ interface SheetOverrides { me?: number base?: string editing?: BudgetItem | null - prefill?: { name?: string; category?: string; amount?: number; reservationId?: number } + prefill?: { name?: string; category?: string; amount?: number; reservationId?: number; placeId?: number } } function renderSheet(overrides: SheetOverrides = {}) { @@ -175,6 +175,19 @@ describe('MCostSheet', () => { })) }) + it('FE-MOB-COSTSH-003b: a place prefill carries the place link into the payload (#1298)', async () => { + renderSheet({ prefill: { name: 'Louvre', category: 'activities', placeId: 12 } }) + expect(nameField()).toHaveValue('Louvre') + + fireEvent.change(totalField(), { target: { value: '34' } }) + fireEvent.click(submit()) + await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1)) + expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ + name: 'Louvre', category: 'activities', total_price: 34, place_id: 12, + })) + expect(addBudgetItem.mock.calls[0][1]).not.toHaveProperty('reservation_id') + }) + it('FE-MOB-COSTSH-004: dropping a participant re-splits and shrinks the member list', async () => { renderSheet() fillBasics('Taxi', '30') diff --git a/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx index b942b02e7..7965daea5 100644 --- a/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx @@ -4,10 +4,11 @@ import MPlaceEditSheet from '../../../../src/mobile/screens/trip/sheets/MPlaceEd import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { Assignment, Category, Place } from '../../../../src/types' import { buildPlanner } from '../../../helpers/mobileTrip' +import { useAddonStore } from '../../../../src/store/addonStore' import { server } from '../../../helpers/msw/server' import { fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-024 +// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-024, plus FE-MOB-PLEDIT-033 to -035 // planner.t echoes the key, so every label/placeholder is asserted as its key. const CATEGORIES = [ @@ -27,8 +28,9 @@ const EDITED = { function setup(overrides: Partial = {}) { const planner = buildPlanner({ showPlaceForm: true, categories: CATEGORIES, ...overrides }) - const view = render() - return { ...view, planner } + const onOpenExpense = vi.fn() + const view = render() + return { ...view, planner, onOpenExpense } } const nameField = () => screen.getByPlaceholderText('places.formNamePlaceholder') @@ -247,7 +249,7 @@ describe('MPlaceEditSheet', () => { it('FE-MOB-PLEDIT-020: shows the saving label and blocks the button while the save runs', async () => { let release: () => void = () => {} - const handleSavePlace = vi.fn(() => new Promise(resolve => { release = resolve })) + const handleSavePlace = vi.fn(() => new Promise<{ id: number }>(resolve => { release = () => resolve({ id: 1 }) })) setup({ handleSavePlace }) fireEvent.change(nameField(), { target: { value: 'Nakamise' } }) fireEvent.click(submit()) @@ -390,4 +392,50 @@ describe('MPlaceEditSheet', () => { it('FE-MOB-PLEDIT-032: sends no bias for a trip without any located place', async () => { expect(await capturedBias([])).toBeUndefined() }) + + // ── Linked expense (#1298) — the same block the booking sheet has ────────── + + describe('Costs', () => { + const withBudget = () => useAddonStore.setState({ + addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }] as never, + loaded: true, + }) + + it('FE-MOB-PLEDIT-033: the button only appears while the Budget addon is on', () => { + const { unmount } = setup() + expect(screen.queryByRole('button', { name: 'reservations.createExpense' })).not.toBeInTheDocument() + unmount() + + withBudget() + setup() + expect(screen.getByRole('button', { name: 'reservations.createExpense' })).toBeInTheDocument() + }) + + it('FE-MOB-PLEDIT-034: creating an expense saves the place first, then opens the editor', async () => { + withBudget() + const handleSavePlace = vi.fn(async () => ({ id: 42 })) + const { onOpenExpense } = setup({ handleSavePlace }) + + fireEvent.change(nameField(), { target: { value: 'Louvre' } }) + fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' })) + + await waitFor(() => expect(onOpenExpense).toHaveBeenCalled()) + expect(handleSavePlace.mock.invocationCallOrder[0]).toBeLessThan(onOpenExpense.mock.invocationCallOrder[0]) + expect(onOpenExpense).toHaveBeenCalledWith({ + prefill: { placeId: 42, name: 'Louvre', category: 'activities' }, + }) + }) + + it('FE-MOB-PLEDIT-035: a save that yields no id opens no editor', async () => { + withBudget() + const handleSavePlace = vi.fn(async () => undefined) + const { onOpenExpense } = setup({ handleSavePlace }) + + fireEvent.change(nameField(), { target: { value: 'Louvre' } }) + fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' })) + + await waitFor(() => expect(handleSavePlace).toHaveBeenCalled()) + expect(onOpenExpense).not.toHaveBeenCalled() + }) + }) }) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index a79cf90cf..dfa0b72c9 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3879,6 +3879,18 @@ function runMigrations(db: Database.Database): void { if (!err.message?.includes('duplicate column name')) throw err; } }, + // #1298 — an expense can hang off a place, exactly as it already hangs off a + // reservation. Same nullable FK, same ON DELETE SET NULL: the delete paths + // take the linked expense with the place themselves, so the constraint is a + // backstop for anything that removes a place without going through them. + // Appended LAST: the array is index-addressed against schema_version, so a + // slot inserted above this line never runs on an existing database. + () => { + const cols = db.prepare("SELECT name FROM pragma_table_info('budget_items')").all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'place_id')) { + db.exec('ALTER TABLE budget_items ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL DEFAULT NULL'); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index 6eb67db8c..d3d6eba31 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -301,6 +301,7 @@ export class BudgetService { persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; ticket_json?: string | null; reservation_id?: number | null; + place_id?: number | null; }, ) { return this.db.transaction(() => { @@ -330,7 +331,7 @@ export class BudgetService { const { note, ticket } = splitLegacyTicketNote(data.note, data.ticket_json); const result = this.db.run( - 'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, ticket_json, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, ticket_json, sort_order, expense_date, reservation_id, place_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', tripId, cat, data.name, @@ -344,6 +345,7 @@ export class BudgetService { sortOrder, data.expense_date || null, data.reservation_id != null ? data.reservation_id : null, + data.place_id != null ? data.place_id : null, ); const itemId = result.lastInsertRowid as number; diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index 23ccf7f8e..9d13543db 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -287,11 +287,17 @@ export class PlacesController { // journey entries even though removeMany below refuses it. Still ahead of // the DELETE — journey_entries.source_place_id is ON DELETE SET NULL, so // afterwards there is nothing left to detach. - for (const id of this.places.scopedIds(tripId, ids)) this.places.onDeleted(id); + const scoped = this.places.scopedIds(tripId, ids); + for (const id of scoped) this.places.onDeleted(id); + // Read the linked expenses before the delete — afterwards the link is gone (#1298). + const expenseIds = this.places.linkedExpenseIds(tripId, scoped); const deleted = this.places.removeMany(tripId, ids); for (const id of deleted) { this.places.broadcast(tripId, 'place:deleted', { placeId: id }, socketId); } + for (const itemId of expenseIds) { + this.places.broadcast(tripId, 'budget:deleted', { itemId }, socketId); + } return { deleted, count: deleted.length }; } @@ -448,10 +454,14 @@ export class PlacesController { throw new HttpException({ error: 'Place not found' }, 404); } this.places.onDeleted(Number(id)); + const expenseIds = this.places.linkedExpenseIds(tripId, [id]); if (!this.places.remove(tripId, id)) { throw new HttpException({ error: 'Place not found' }, 404); } this.places.broadcast(tripId, 'place:deleted', { placeId: Number(id) }, socketId); + for (const itemId of expenseIds) { + this.places.broadcast(tripId, 'budget:deleted', { itemId }, socketId); + } return { success: true }; } } diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 79a3a943b..39c88127f 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -232,9 +232,12 @@ export class PlacesMcp { return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; } try { this.journey.onPlaceDeleted(placeId); } catch { /* non-fatal */ } // sync journeys before the row is gone + // The link is gone once the place is, so read it first (#1298). + const expenseIds = this.places.linkedExpenseIds(tripId, [placeId]); const deleted = this.places.remove(String(tripId), String(placeId)); if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; this.guards.safeBroadcast(tripId, 'place:deleted', { placeId }); + for (const itemId of expenseIds) this.guards.safeBroadcast(tripId, 'budget:deleted', { itemId }); return ok({ success: true }); } @@ -337,11 +340,15 @@ export class PlacesMcp { // Trip-scoped, and ahead of the DELETE: journey_entries.source_place_id is // ON DELETE SET NULL, so a hook that ran afterwards found nothing left to // detach and left the entries as orphans. - for (const id of this.places.scopedIds(String(tripId), placeIds)) { + const scoped = this.places.scopedIds(String(tripId), placeIds); + for (const id of scoped) { try { this.journey.onPlaceDeleted(id); } catch { /* non-fatal */ } } + // The link is gone once the places are, so read it first (#1298). + const expenseIds = this.places.linkedExpenseIds(tripId, scoped); const deleted = this.places.removeMany(String(tripId), placeIds); for (const id of deleted) this.guards.safeBroadcast(tripId, 'place:deleted', { placeId: id }); + for (const itemId of expenseIds) this.guards.safeBroadcast(tripId, 'budget:deleted', { itemId }); return ok({ deleted, count: deleted.length }); } diff --git a/server/src/nest/places/places.rpc.ts b/server/src/nest/places/places.rpc.ts index b5474973c..3f61da812 100644 --- a/server/src/nest/places/places.rpc.ts +++ b/server/src/nest/places/places.rpc.ts @@ -80,10 +80,13 @@ export class PlacesRpc { // journey_entries.source_place_id is ON DELETE SET NULL, so afterwards the hook // finds nothing left to detach and the entries linger as orphans. this.mirrorJourneys(() => this.journey.onPlaceDeleted(placeId)); + // The link is gone once the place is, so read it first (#1298). + const expenseIds = this.places.linkedExpenseIds(tripId, [placeId]); if (!this.places.remove(String(tripId), String(placeId))) { throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`); } this.realtime.broadcast(tripId, 'place:deleted', { placeId }); + for (const itemId of expenseIds) this.realtime.broadcast(tripId, 'budget:deleted', { itemId }); return { deleted: true }; } diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 26cdf9b8f..ceef2576d 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -343,12 +343,32 @@ export class PlacesService { // Delete place // ------------------------------------------------------------------------- + /** + * The expenses hanging off these places (#1298), so a caller can broadcast the + * budget:deleted events for the rows remove()/removeMany() are about to take + * with them. Read it BEFORE deleting — afterwards the link is gone. + */ + linkedExpenseIds(tripId: string | number, placeIds: Array): number[] { + if (placeIds.length === 0) return []; + const rows = this.dbs.all<{ id: number }>( + `SELECT id FROM budget_items WHERE trip_id = ? AND place_id IN (${placeIds.map(() => '?').join(',')})`, + tripId, ...placeIds, + ); + return rows.map(r => r.id); + } + remove(tripId: string, placeId: string): boolean { const place = this.dbs.get<{ google_place_id: string | null; image_url: string | null }>( 'SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?', placeId, tripId, ); if (!place) return false; - this.dbs.run('DELETE FROM places WHERE id = ?', placeId); + // The linked expense goes with the place, the same way a booking takes its + // expense with it (#1298). One transaction, so a place can never survive + // half-detached from its money. + this.dbs.transaction(() => { + this.dbs.run('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?', tripId, placeId); + this.dbs.run('DELETE FROM places WHERE id = ?', placeId); + }); reclaimPhotoCache(this.photoCache, place.google_place_id, place.image_url); reclaimPlaceImage(place.image_url); return true; @@ -358,12 +378,14 @@ export class PlacesService { if (ids.length === 0) return []; const selectStmt = this.dbs.prepare('SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?'); const deleteStmt = this.dbs.prepare('DELETE FROM places WHERE id = ?'); + const deleteExpenseStmt = this.dbs.prepare('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?'); const deleted: number[] = []; const reclaimable: { google_place_id: string | null; image_url: string | null }[] = []; this.dbs.transaction(() => { for (const id of ids) { const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined; if (!row) continue; + deleteExpenseStmt.run(tripId, id); deleteStmt.run(id); deleted.push(id); reclaimable.push(row); diff --git a/server/src/types.ts b/server/src/types.ts index 1f0f601ce..4187e4622 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -139,6 +139,9 @@ export interface BudgetItem { /** Itemized receipt for a per-item split, as JSON. Its own column since #1658. */ ticket_json?: string | null; reservation_id?: number | null; + /** Set when the expense was created from a place (#1298) — the other side of + * the same link reservation_id is for a booking. */ + place_id?: number | null; paid_by_user_id?: number | null; expense_date?: string | null; sort_order: number; diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index a85bb26ea..abaed821b 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -47,6 +47,9 @@ const { db } = vi.hoisted(() => { tmp.exec(`CREATE TABLE collection_places (id INTEGER PRIMARY KEY AUTOINCREMENT, image_url TEXT);`); // reclaimPhotoCache's removeIfUnreferenced sweeps the Google photo cache. tmp.exec(`CREATE TABLE google_place_photo_meta (place_id TEXT PRIMARY KEY, attribution TEXT, error_at DATETIME);`); + // Deleting a place takes its linked expense with it (#1298). + tmp.exec(`CREATE TABLE budget_items (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + name TEXT, total_price REAL DEFAULT 0, place_id INTEGER, reservation_id INTEGER);`); return { db: tmp }; }); @@ -260,6 +263,17 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { expect(foreign.body).toEqual({ error: 'Place not found' }); }); + it('DELETE :id takes the expense linked to the place with it (#1298)', async () => { + db.prepare("INSERT INTO places (id, trip_id, name) VALUES (12, 5, 'Louvre')").run(); + db.prepare("INSERT INTO budget_items (id, trip_id, name, total_price, place_id) VALUES (44, 5, 'Tickets', 34, 12)").run(); + db.prepare("INSERT INTO budget_items (id, trip_id, name, total_price) VALUES (45, 5, 'Coffee', 3)").run(); + + const res = await request(server).delete('/api/trips/5/places/12').set('Cookie', sessionCookie(1)); + + expect(res.status).toBe(200); + expect(db.prepare('SELECT id FROM budget_items ORDER BY id').all()).toEqual([{ id: 45 }]); + }); + it('404 trip when not accessible', async () => { canAccessTrip.mockReturnValue(undefined); const res = await request(server).get('/api/trips/5/places').set('Cookie', sessionCookie(1)); diff --git a/server/tests/integration/leg-mode-incoming.test.ts b/server/tests/integration/leg-mode-incoming.test.ts index 1e2adcad7..4fb2739f6 100644 --- a/server/tests/integration/leg-mode-incoming.test.ts +++ b/server/tests/integration/leg-mode-incoming.test.ts @@ -34,23 +34,32 @@ describe('incoming_leg_transport_mode migration', () => { }); // The case above starts from an empty database and runs every migration, so it - // passes wherever this one sits in the array. Existing installs replay only the - // slots above their schema_version, which is why the migration has to be the - // trailing entry: from anywhere but the end it is silently skipped on upgrade. - it('still lands on a database that already holds every earlier migration', () => { + // passes wherever a migration sits in the array. + it('the trailing migration lands on a database that is one version behind', () => { + // What actually has to hold: existing installs replay only the slots above + // their schema_version, so a migration inserted mid-array is silently + // skipped on every one of them. Undoing what the LAST migration did and + // rewinding one version is the only way to prove it still runs. + // + // >>> Appending a migration? Re-point the DROP below at the column yours + // >>> adds. That is the whole maintenance cost of this guard. + const TRAILING_TABLE = 'budget_items'; + const TRAILING_COLUMN = 'place_id'; + const upgraded = new Database(':memory:'); upgraded.exec('PRAGMA foreign_keys = ON'); createTables(upgraded); runMigrations(upgraded); const { version } = upgraded.prepare('SELECT version FROM schema_version').get() as { version: number }; - upgraded.exec('ALTER TABLE day_assignments DROP COLUMN incoming_leg_transport_mode'); + upgraded.exec(`ALTER TABLE ${TRAILING_TABLE} DROP COLUMN ${TRAILING_COLUMN}`); upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 1); runMigrations(upgraded); - const cols = upgraded.prepare(`PRAGMA table_info(day_assignments)`).all() as { name: string }[]; - expect(cols.map(c => c.name)).toContain('incoming_leg_transport_mode'); + const cols = upgraded.prepare(`PRAGMA table_info(${TRAILING_TABLE})`).all() as { name: string }[]; + expect(cols.map(c => c.name)).toContain(TRAILING_COLUMN); + expect(upgraded.prepare('SELECT version FROM schema_version').get()).toEqual({ version }); upgraded.close(); }); }); diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index d8b5b4aa2..e34071447 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -291,6 +291,21 @@ describe('Tool: delete_place', () => { }); }); + it('takes the linked expense with it and announces that too (#1298)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + const itemId = Number(testDb + .prepare("INSERT INTO budget_items (trip_id, name, total_price, place_id) VALUES (?, 'Tickets', 34, ?)") + .run(trip.id, place.id).lastInsertRowid); + + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } }); + expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(itemId)).toBeUndefined(); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:deleted', expect.objectContaining({ itemId })); + }); + }); + it('broadcasts place:deleted event', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 2a6ad8445..6c363276b 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -452,6 +452,31 @@ describe('composite service paths (ex budget.bridge delegation)', () => { const row = testDb.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(item.id) as { reservation_id: number | null }; expect(row.reservation_id).toBe(reservationId); }); + + it('BUDGET-SVC-DB-018b: an expense can be created against a place (#1298)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const placeId = Number(testDb + .prepare("INSERT INTO places (trip_id, name) VALUES (?, 'Louvre')") + .run(trip.id).lastInsertRowid); + + const item = budget.createBudgetItem(trip.id, { name: 'Louvre tickets', total_price: 34, place_id: placeId }); + + expect(item.place_id).toBe(placeId); + expect(item.reservation_id).toBeNull(); + const row = testDb.prepare('SELECT place_id FROM budget_items WHERE id = ?').get(item.id) as { place_id: number | null }; + expect(row.place_id).toBe(placeId); + }); + + it('BUDGET-SVC-DB-018c: an expense without a link stores neither id', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const item = budget.createBudgetItem(trip.id, { name: 'Coffee', total_price: 3 }); + + expect(item.place_id).toBeNull(); + expect(item.reservation_id).toBeNull(); + }); }); describe('post-fold quirk fixes', () => { diff --git a/server/tests/unit/nest/places.controller.test.ts b/server/tests/unit/nest/places.controller.test.ts index 973f798c4..12f4af731 100644 --- a/server/tests/unit/nest/places.controller.test.ts +++ b/server/tests/unit/nest/places.controller.test.ts @@ -15,6 +15,7 @@ function svc(o: Partial = {}): PlacesService { // Trip-scoping reads the delete paths run before firing the journey hook // (#1745); default to "everything belongs to the trip". get: vi.fn().mockReturnValue({ id: 9 }), scopedIds: vi.fn((_t: string, ids: number[]) => ids), + linkedExpenseIds: vi.fn().mockReturnValue([]), ...o, } as unknown as PlacesService; } @@ -208,6 +209,19 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou expect(onDeleted).toHaveBeenCalledWith(1); expect(onDeleted.mock.invocationCallOrder[0]).toBeLessThan(removeMany.mock.invocationCallOrder[0]); }); + + // #1298: the link is gone once the place is, so the ids have to be read first. + it('announces the expenses the deleted places took with them', () => { + const removeMany = vi.fn().mockReturnValue([1, 2]); + const linkedExpenseIds = vi.fn().mockReturnValue([77]); + const broadcast = vi.fn(); + const s = svc({ removeMany, linkedExpenseIds, broadcast } as Partial); + new PlacesController(s, new RuntimeEnvService()).bulkDelete(user, '5', { ids: [1, 2] }, 'sock'); + + expect(linkedExpenseIds).toHaveBeenCalledWith('5', [1, 2]); + expect(linkedExpenseIds.mock.invocationCallOrder[0]).toBeLessThan(removeMany.mock.invocationCallOrder[0]); + expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 77 }, 'sock'); + }); }); describe('POST /bulk-update', () => { diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index 77bde3de3..e98decef5 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -393,6 +393,51 @@ describe('remove', () => { expect(remaining[0].id).toBe(p1.id); }); + it('PLACE-SVC-019c — the linked expense goes with the place (#1298)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Louvre' }) as any; + const other = createPlace(testDb, trip.id, { name: 'Orsay', lat: 48.86, lng: 2.3266 }) as any; + const linked = Number(testDb.prepare("INSERT INTO budget_items (trip_id, name, total_price, place_id) VALUES (?, 'Tickets', 34, ?)").run(trip.id, place.id).lastInsertRowid); + const untouched = Number(testDb.prepare("INSERT INTO budget_items (trip_id, name, total_price, place_id) VALUES (?, 'Other tickets', 12, ?)").run(trip.id, other.id).lastInsertRowid); + const standalone = Number(testDb.prepare("INSERT INTO budget_items (trip_id, name, total_price) VALUES (?, 'Coffee', 3)").run(trip.id).lastInsertRowid); + + // Read the link before the delete — that is what the controller broadcasts. + expect(svc.linkedExpenseIds(trip.id, [place.id])).toEqual([linked]); + expect(svc.remove(String(trip.id), String(place.id))).toBe(true); + + const rows = testDb.prepare('SELECT id FROM budget_items ORDER BY id').all() as { id: number }[]; + expect(rows.map(r => r.id)).toEqual([untouched, standalone]); + }); + + it('PLACE-SVC-019d — removeMany takes the expense of every deleted place with it', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const a = createPlace(testDb, trip.id, { name: 'A' }) as any; + const b = createPlace(testDb, trip.id, { name: 'B', lat: 48.86, lng: 2.3266 }) as any; + const keep = createPlace(testDb, trip.id, { name: 'C', lat: 48.87, lng: 2.34 }) as any; + for (const p of [a, b, keep]) { + testDb.prepare("INSERT INTO budget_items (trip_id, name, total_price, place_id) VALUES (?, 'x', 1, ?)").run(trip.id, p.id); + } + + expect(svc.linkedExpenseIds(trip.id, [a.id, b.id])).toHaveLength(2); + svc.removeMany(String(trip.id), [a.id, b.id]); + + const rows = testDb.prepare('SELECT place_id FROM budget_items').all() as { place_id: number }[]; + expect(rows.map(r => r.place_id)).toEqual([keep.id]); + }); + + it('PLACE-SVC-019e — linkedExpenseIds ignores places of another trip and an empty list', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + const place = createPlace(testDb, other.id, { name: 'Elsewhere' }) as any; + testDb.prepare("INSERT INTO budget_items (trip_id, name, total_price, place_id) VALUES (?, 'x', 1, ?)").run(other.id, place.id); + + expect(svc.linkedExpenseIds(trip.id, [place.id])).toEqual([]); + expect(svc.linkedExpenseIds(trip.id, [])).toEqual([]); + }); + it('PLACE-SVC-019b — reclaims the photo cache for the deleted place', () => { removeIfUnreferencedSpy.mockClear(); const { user } = createUser(testDb); diff --git a/server/tests/unit/places/places.rpc.test.ts b/server/tests/unit/places/places.rpc.test.ts index c807d8232..95ec337c5 100644 --- a/server/tests/unit/places/places.rpc.test.ts +++ b/server/tests/unit/places/places.rpc.test.ts @@ -37,6 +37,7 @@ function build(opts: { canEdit?: boolean; journeyThrows?: boolean } = {}) { update: vi.fn((_t: string, id: string) => (id === '7' ? { id: 7, name: 'updated' } : null)), get: vi.fn((_t: string, id: string) => (id === '7' ? { id: 7 } : undefined)), remove: vi.fn((_t: string, id: string) => id === '7'), + linkedExpenseIds: vi.fn(() => []), }; const journey = { onPlaceCreated: vi.fn(() => { if (opts.journeyThrows) throw new Error('journey down'); }), diff --git a/server/tests/unit/plugins/rpc-fallbacks.test.ts b/server/tests/unit/plugins/rpc-fallbacks.test.ts index d5682dcb8..19bc9ebfa 100644 --- a/server/tests/unit/plugins/rpc-fallbacks.test.ts +++ b/server/tests/unit/plugins/rpc-fallbacks.test.ts @@ -107,7 +107,7 @@ describe('optional fields fall back rather than reaching the service as undefine describe('a service that says no becomes a refusal, not a crash', () => { it('FALLBACK-006 a place that will not delete is refused', async () => { const { guards } = guardsFor(); - const places = { get: vi.fn(() => ({ id: 7 })), remove: vi.fn(() => false), create: vi.fn(), update: vi.fn() } as never; + const places = { get: vi.fn(() => ({ id: 7 })), remove: vi.fn(() => false), create: vi.fn(), update: vi.fn(), linkedExpenseIds: vi.fn(() => []) } as never; const journey = { onPlaceDeleted: vi.fn(), onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn() } as never; const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new PlacesRpc(places, journey, realtime(), guards)])); expect(err(await host.dispatch(req('places.delete', { tripId: 1, placeId: 7 }), 42)).message).toBe('no place 7 on trip 1'); diff --git a/shared/src/budget/budget.schema.ts b/shared/src/budget/budget.schema.ts index e6b4178b8..e2355a3d7 100644 --- a/shared/src/budget/budget.schema.ts +++ b/shared/src/budget/budget.schema.ts @@ -116,6 +116,9 @@ export const budgetItemSchema = z.object({ /** Itemized receipt behind a per-item split, as JSON. Its own column since #1658. */ ticket_json: z.string().nullable().optional(), reservation_id: z.number().nullable().optional(), + /** Set when the expense was created from a place (#1298) — the same link + * reservation_id is for a booking, on the other side of the planner. */ + place_id: z.number().nullable().optional(), paid_by_user_id: z.number().nullable().optional(), expense_date: z.string().nullable().optional(), sort_order: z.number().optional(), @@ -155,6 +158,9 @@ export const budgetCreateItemRequestSchema = z.object({ // Link this expense to a reservation (e.g. created from a booking's // "add expense" flow). The server stores it on budget_items.reservation_id. reservation_id: z.number().optional(), + // The same for a place: the place form's "add expense" flow saves the place + // first, then creates the expense against it (#1298). + place_id: z.number().optional(), }); export type BudgetCreateItemRequest = z.infer; diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index 197457b30..18756faf0 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'فشل إنشاء الفئة', 'places.nameRequired': 'يرجى إدخال اسم', 'places.saveError': 'فشل الحفظ', + 'places.createExpenseHint': 'يحفظ المكان ثم يفتح محرر التكاليف.', 'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.", 'places.addAnyway': 'الإضافة على أي حال', 'places.enrichOnImport': 'إثراء الأماكن عبر Google', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index c92fd76aa..0db968490 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Falha ao criar categoria', 'places.nameRequired': 'Digite um nome', 'places.saveError': 'Falha ao salvar', + 'places.createExpenseHint': 'Salva o local e depois abre o editor de despesas.', 'places.duplicateExists': "'{name}' já está nesta viagem.", 'places.addAnyway': 'Adicionar mesmo assim', 'places.enrichOnImport': 'Enriquecer lugares via Google', diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index 56d3280f1..fa38f3d7e 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -113,6 +113,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': "No s'ha pogut crear la categoria", 'places.nameRequired': 'Introdueix un nom', 'places.saveError': "No s'ha pogut desar", + 'places.createExpenseHint': 'Desa el lloc i després obre l’editor de despeses.', 'places.changeCategory': 'Canviar categoria', 'places.categoryChanged': '{count} llocs actualitzats', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index e8073085d..c971bc1e4 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii', 'places.nameRequired': 'Prosím zadejte název', 'places.saveError': 'Uložení se nezdařilo', + 'places.createExpenseHint': 'Uloží místo a poté otevře editor nákladů.', 'places.duplicateExists': "'{name}' už v tomto výletu existuje.", 'places.addAnyway': 'Přesto přidat', 'places.enrichOnImport': 'Obohatit místa přes Google', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index 46f655dd8..868b06b5b 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie', 'places.nameRequired': 'Bitte einen Namen eingeben', 'places.saveError': 'Fehler beim Speichern', + 'places.createExpenseHint': 'Speichert den Ort und öffnet dann den Kosten-Editor.', 'places.duplicateExists': "'{name}' ist bereits in dieser Reise.", 'places.uploadImage': 'Bild hochladen', 'places.changeImage': 'Bild ändern', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index 7795ee8a5..f37966208 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Failed to create category', 'places.nameRequired': 'Please enter a name', 'places.saveError': 'Failed to save', + 'places.createExpenseHint': 'Saves the place, then opens the Costs editor.', 'places.duplicateExists': "'{name}' is already in this trip.", 'places.addAnyway': 'Add anyway', 'places.enrichOnImport': 'Enrich places via Google', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index b405f2905..8c23d0b60 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'No se pudo crear la categoría', 'places.nameRequired': 'Introduce un nombre', 'places.saveError': 'No se pudo guardar', + 'places.createExpenseHint': 'Guarda el lugar y luego abre el editor de gastos.', 'places.duplicateExists': "'{name}' ya está en este viaje.", 'places.addAnyway': 'Añadir de todos modos', 'places.enrichOnImport': 'Enriquecer lugares con Google', diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index 1b1c71851..b98849abc 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Impossible de créer la catégorie', 'places.nameRequired': 'Veuillez saisir un nom', 'places.saveError': "Échec de l'enregistrement", + 'places.createExpenseHint': 'Enregistre le lieu, puis ouvre l’éditeur de dépenses.', 'places.duplicateExists': "'{name}' est déjà dans ce voyage.", 'places.addAnyway': 'Ajouter quand même', 'places.enrichOnImport': 'Enrichir les lieux via Google', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index d721161c2..8ac900e71 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Αποτυχία δημιουργίας κατηγορίας', 'places.nameRequired': 'Παρακαλώ εισαγάγετε ένα όνομα', 'places.saveError': 'Αποτυχία αποθήκευσης', + 'places.createExpenseHint': 'Αποθηκεύει την τοποθεσία και ανοίγει τον επεξεργαστή εξόδων.', 'places.duplicateExists': "Το '{name}' υπάρχει ήδη σε αυτό το ταξίδι.", 'places.addAnyway': 'Προσθήκη ούτως ή άλλως', 'places.enrichOnImport': 'Εμπλουτισμός τόπων μέσω Google', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index 3cb41240c..d90046f60 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát', 'places.nameRequired': 'Kérjük, adj meg egy nevet', 'places.saveError': 'Nem sikerült menteni', + 'places.createExpenseHint': 'Menti a helyet, majd megnyitja a költségszerkesztőt.', 'places.duplicateExists': "A(z) '{name}' már szerepel ebben az utazásban.", 'places.addAnyway': 'Hozzáadás mindenképp', 'places.enrichOnImport': 'Helyek gazdagítása a Google-lel', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index ae57bb480..a3b3a9eb3 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -114,6 +114,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Gagal membuat kategori', 'places.nameRequired': 'Harap masukkan nama', 'places.saveError': 'Gagal menyimpan', + 'places.createExpenseHint': 'Menyimpan tempat, lalu membuka editor biaya.', 'places.duplicateExists': "'{name}' sudah ada di perjalanan ini.", 'places.addAnyway': 'Tetap tambahkan', 'places.enrichOnImport': 'Perkaya tempat via Google', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index 812bb1272..7556a1b4b 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Impossibile creare la categoria', 'places.nameRequired': 'Inserisci un nome', 'places.saveError': 'Impossibile salvare', + 'places.createExpenseHint': 'Salva il luogo e poi apre l’editor dei costi.', 'places.duplicateExists': "'{name}' è già in questo viaggio.", 'places.addAnyway': 'Aggiungi comunque', 'places.enrichOnImport': 'Arricchisci i luoghi con Google', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index c358d1d1c..f09f7ee79 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'カテゴリの作成に失敗しました', 'places.nameRequired': '名前を入力してください', 'places.saveError': '保存に失敗しました', + 'places.createExpenseHint': '場所を保存してから費用エディタを開きます。', 'places.duplicateExists': '「{name}」はすでにこの旅程に含まれています。', 'places.addAnyway': 'それでも追加', 'places.enrichOnImport': 'Googleで場所を補完', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index 03139024f..22b4385fe 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -114,6 +114,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': '카테고리 생성 실패', 'places.nameRequired': '이름을 입력하세요', 'places.saveError': '저장 실패', + 'places.createExpenseHint': '장소를 저장한 뒤 비용 편집기를 엽니다.', 'places.duplicateExists': "'{name}'은(는) 이미 이 여행에 있습니다.", 'places.addAnyway': '그래도 추가', 'places.enrichOnImport': 'Google로 장소 정보 보강', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index 6c0c6f008..f2f6f5c1a 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Categorie aanmaken mislukt', 'places.nameRequired': 'Voer een naam in', 'places.saveError': 'Opslaan mislukt', + 'places.createExpenseHint': 'Slaat de plaats op en opent daarna de kosten-editor.', 'places.duplicateExists': "'{name}' staat al in deze reis.", 'places.addAnyway': 'Toch toevoegen', 'places.enrichOnImport': 'Plaatsen verrijken via Google', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index 224be55ab..7a93196a0 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -106,6 +106,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Nie udało się utworzyć kategorii', 'places.nameRequired': 'Proszę podać nazwę', 'places.saveError': 'Nie udało się zapisać', + 'places.createExpenseHint': 'Zapisuje miejsce i otwiera edytor kosztów.', 'places.duplicateExists': "'{name}' jest już w tej podróży.", 'places.addAnyway': 'Dodaj mimo to', 'places.importNaverList': 'Lista Naver', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index d154d1eb2..83740a7df 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Не удалось создать категорию', 'places.nameRequired': 'Введите название', 'places.saveError': 'Ошибка сохранения', + 'places.createExpenseHint': 'Сохраняет место и открывает редактор расходов.', 'places.duplicateExists': "'{name}' уже есть в этой поездке.", 'places.addAnyway': 'Всё равно добавить', 'places.enrichOnImport': 'Обогатить места через Google', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index 4e29e62c8..068a95815 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Det gick inte att skapa kategorin', 'places.nameRequired': 'Ange ett namn', 'places.saveError': 'Det gick inte att spara', + 'places.createExpenseHint': 'Sparar platsen och öppnar sedan kostnadsredigeraren.', 'places.duplicateExists': "'{name}' ingår redan i resan.", 'places.addAnyway': 'Lägg till ändå', 'places.enrichOnImport': 'Berika platser via Google', diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index b6dc3cfe6..bb4e97a27 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -117,6 +117,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Kategori oluşturulamadı', 'places.nameRequired': 'Lütfen bir ad girin', 'places.saveError': 'Kaydedilemedi', + 'places.createExpenseHint': 'Yeri kaydeder, sonra masraf düzenleyicisini açar.', 'places.duplicateExists': "'{name}' zaten bu gezide var.", 'places.addAnyway': 'Yine de ekle', 'places.enrichOnImport': 'Yerleri Google ile zenginleştir', diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index efde4db2e..47af6cfe6 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Не вдалося створити категорію', 'places.nameRequired': 'Введіть назву', 'places.saveError': 'Помилка збереження', + 'places.createExpenseHint': 'Зберігає місце й відкриває редактор витрат.', 'places.duplicateExists': "'{name}' вже є в цій подорожі.", 'places.addAnyway': 'Все одно додати', 'places.enrichOnImport': 'Збагатити місця через Google', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index 6cbee89bf..aadcc18ec 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -115,6 +115,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Không tạo được danh mục', 'places.nameRequired': 'Vui lòng nhập tên', 'places.saveError': 'Không lưu được', + 'places.createExpenseHint': 'Lưu địa điểm rồi mở trình sửa chi phí.', 'places.duplicateExists': "'{name}' đã có trong chuyến đi này.", 'places.addAnyway': 'Vẫn thêm', 'places.enrichOnImport': 'Làm phong phú các địa điểm thông qua Google', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index b05b4dfcd..a319d8081 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -113,6 +113,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': '建立分類失敗', 'places.nameRequired': '請輸入名稱', 'places.saveError': '儲存失敗', + 'places.createExpenseHint': '先儲存地點,然後開啟費用編輯器。', 'places.duplicateExists': "'{name}' 已在此行程中。", 'places.addAnyway': '仍要新增', 'places.enrichOnImport': '透過 Google 豐富地點資訊', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index acc141c99..02a7b6eb1 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -113,6 +113,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': '创建分类失败', 'places.nameRequired': '请输入名称', 'places.saveError': '保存失败', + 'places.createExpenseHint': '先保存地点,然后打开费用编辑器。', 'places.duplicateExists': "'{name}' 已在此行程中。", 'places.addAnyway': '仍然添加', 'places.enrichOnImport': '通过 Google 丰富地点信息', diff --git a/wiki/Budget-Tracking.md b/wiki/Budget-Tracking.md index e70f4b89f..d4c522bae 100644 --- a/wiki/Budget-Tracking.md +++ b/wiki/Budget-Tracking.md @@ -59,6 +59,12 @@ Click any editable cell to edit it inline. Drag the grip handle to reorder items Add a new item using the inline **add row** at the bottom of each category table. +### Expenses linked to a booking or a place + +An expense can hang off a **booking** (reservation or transport) or off a **place** — both offer a **Create expense** button in their form, which saves the record first and then opens the expense editor for it. A linked expense is an ordinary expense: it takes a payer, a split, a date and a currency like any other, and it shows up in the settlement. + +Deleting the booking or the place deletes its linked expense with it. Removing the expense from the record's Costs block deletes only the expense and leaves the record standing. + ## Splitting costs The **Persons** column behaves differently depending on the trip: diff --git a/wiki/Places-and-Search.md b/wiki/Places-and-Search.md index 87016c791..77cfc76ec 100644 --- a/wiki/Places-and-Search.md +++ b/wiki/Places-and-Search.md @@ -79,6 +79,14 @@ Type or paste a `lat, lng` pair (e.g. `48.8566, 2.3522`) into the **Latitude** f Two inline warnings are shown when editing times: one if the end time is set to a value before or equal to the start time, and one if the times overlap with another place already assigned to the same day. +## Costs for a place + +With the [Costs/Budget addon](Budget-Tracking) enabled, the place form carries the same **Costs** block that bookings and transports have. **Create expense** saves the place and then opens the Costs editor for a new expense linked to it — the museum ticket, the guided tour, the entry fee. Once linked, the block shows that expense with edit and remove actions. + +The expense belongs to the **place**, not to a day. Putting the same place on several days does not multiply it: you bought the ticket once. If you really pay each time, add a second expense from the Costs tab. + +Deleting the place deletes its linked expense too, the same way deleting a booking does. + ## Custom place image By default a place's thumbnail is fetched automatically (from Google/OpenStreetMap when the place was imported or matched, otherwise a category icon). To use your own photo instead, open the place's detail panel and click its round thumbnail — pick an image and it becomes that place's thumbnail everywhere (list, map marker, itinerary, PDF export and shared trips). A small remove button on the thumbnail clears the custom image and restores the automatic default. Accepted formats are JPG, PNG, GIF and WebP (HEIC is converted automatically), up to 20 MB. diff --git a/wiki/Reservations-and-Bookings.md b/wiki/Reservations-and-Bookings.md index e8b09981c..e12801c8f 100644 --- a/wiki/Reservations-and-Bookings.md +++ b/wiki/Reservations-and-Bookings.md @@ -73,7 +73,7 @@ Click **Add** (or the + button) in the Reservations panel. Fill in the form: 9. **Hotel-specific fields** — shown only for Hotel type, immediately after status: hotel place, check-in day, check-out day, check-in time (window start and end), and check-out time. See [Accommodations](Accommodations) 10. **Notes** 11. **Files** — attach from your device (PDF, Word documents, text files, images) or link an existing trip file. Files added before saving are uploaded automatically after the reservation is created -12. **Price and budget category** — shown only when the Budget addon is enabled. Entering a price greater than zero automatically creates a linked budget entry. See [Budget-Tracking](Budget-Tracking) +12. **Costs** — shown only when the Budget addon is enabled. Instead of a price field, the form carries a **Create expense** button: it saves the booking and then opens the Costs editor for a new expense linked to it, so the expense gets a payer, a split and a date like any other. Once linked, the block shows that expense with edit and remove actions. See [Budget-Tracking](Budget-Tracking) From 9dd0a851cab6688c8d2f4d5a10750bfd0084c638 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:06:35 +0200 Subject: [PATCH 04/20] 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, 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 --- client/e2e/urlbar-scroll-1809.spec.ts | 105 ++++ .../Budget/CostsPanel.helpers.test.ts | 46 +- .../components/Budget/CostsPanel.helpers.ts | 24 +- .../src/components/Budget/CostsPanel.test.tsx | 6 +- client/src/components/Budget/CostsPanel.tsx | 10 +- .../AddPlaceToCollectionModal.test.tsx | 67 ++- .../Collections/AddPlaceToCollectionModal.tsx | 97 +-- .../CollectionPlaceDetail.test.tsx | 57 +- .../Collections/CollectionPlaceDetail.tsx | 13 +- .../components/PDF/JourneyBookPDF.test.tsx | 98 ++- client/src/components/PDF/JourneyBookPDF.tsx | 119 +++- .../Settings/LlmConnectionSection.test.tsx | 120 ++-- .../Settings/LlmConnectionSection.tsx | 93 +-- .../SystemNotices/SystemNoticeModal.tsx | 16 +- .../Vacay/VacaySettings.calendars.test.tsx | 82 ++- client/src/components/Vacay/VacaySettings.tsx | 55 +- client/src/components/shared/Modal.test.tsx | 16 + client/src/components/shared/Modal.tsx | 20 +- client/src/index.css | 24 + client/src/mobile/MobileShell.tsx | 29 +- client/src/mobile/components/MBottomNav.tsx | 12 +- client/src/mobile/components/MSheet.tsx | 22 +- client/src/mobile/screens/admin/MAdmin.tsx | 4 +- client/src/mobile/screens/atlas/MAtlas.tsx | 6 +- .../screens/atlas/MAtlasCountryPopup.test.tsx | 59 +- .../screens/atlas/MAtlasCountryPopup.tsx | 20 +- .../screens/collections/MCollAddSheet.tsx | 18 +- .../screens/collections/MCollPlaceSheet.tsx | 10 +- .../mobile/screens/dashboard/MDashboard.tsx | 4 +- .../src/mobile/screens/journey/MJourney.tsx | 5 +- .../mobile/screens/journey/MJourneyDetail.tsx | 86 ++- .../screens/journey/MJourneyEntrySheet.tsx | 213 ++++++- .../settings/MLlmConnectionSection.tsx | 73 +-- .../screens/trip/tabs/MFileLightbox.tsx | 7 +- client/src/mobile/screens/vacay/MVacay.tsx | 23 +- .../src/mobile/screens/vacay/MVacayMonth.tsx | 1 + .../screens/vacay/MVacaySettingsSheet.tsx | 50 +- client/src/mobile/screens/vacay/useMVacay.ts | 25 +- .../src/mobile/screens/vacay/vacayDayModel.ts | 19 +- client/src/pages/AtlasPage.tsx | 12 +- client/src/pages/AtlasPage.wiring.test.tsx | 20 + client/src/pages/JourneyDetailPage.tsx | 80 ++- .../pages/JourneyDetailPage.wiring.test.tsx | 54 +- client/src/pages/atlas/atlasModel.test.ts | 58 ++ client/src/pages/atlas/atlasModel.ts | 38 ++ client/src/pages/atlas/useAtlas.test.tsx | 37 ++ client/src/pages/atlas/useAtlas.ts | 24 +- .../pages/journeyDetail/useJourneyDetail.ts | 8 +- client/src/store/collectionStore.test.ts | 33 +- client/src/store/collectionStore.ts | 85 ++- client/src/utils/bodyScrollLock.test.ts | 58 ++ client/src/utils/bodyScrollLock.ts | 43 ++ client/tests/unit/mobile/MBottomNav.test.tsx | 63 ++ client/tests/unit/mobile/MobileShell.test.tsx | 60 ++ .../tests/unit/mobile/admin/MAdmin.test.tsx | 11 + .../tests/unit/mobile/atlas/MAtlas.test.tsx | 13 +- .../mobile/collections/MCollAddSheet.test.tsx | 42 +- .../collections/MCollPlaceSheet.test.tsx | 44 +- .../unit/mobile/dashboard/MDashboard.test.tsx | 15 +- .../unit/mobile/journey/MJourney.test.tsx | 12 +- .../mobile/journey/MJourneyDetail.test.tsx | 92 ++- .../journey/MJourneyEntrySheet.test.tsx | 169 +++++- .../settings/MLlmConnectionSection.test.tsx | 118 +++- .../tests/unit/mobile/vacay/MVacay.test.tsx | 23 +- .../mobile/vacay/MVacaySettingsSheet.test.tsx | 84 ++- .../unit/mobile/vacay/useMVacay.test.tsx | 47 +- .../unit/mobile/vacay/vacayDayModel.test.ts | 52 +- client/tests/unit/sync/tilePrefetcher.test.ts | 42 ++ client/vite.config.js | 35 +- .../accommodations.controller.ts | 10 +- .../nest/accommodations/accommodations.mcp.ts | 8 +- .../nest/accommodations/accommodations.rpc.ts | 6 +- .../accommodations/accommodations.service.ts | 51 +- server/src/nest/atlas/atlas.controller.ts | 29 +- server/src/nest/atlas/atlas.mcp.ts | 34 +- server/src/nest/atlas/atlas.rpc.ts | 11 +- server/src/nest/atlas/atlas.service.ts | 96 ++- server/src/nest/budget/budget.service.ts | 134 +++-- server/src/nest/calendar/calendar.service.ts | 30 +- .../src/nest/collections/collections.mcp.ts | 2 +- .../nest/collections/collections.service.ts | 1 + .../src/nest/llm-parse/llm-config.resolver.ts | 28 +- .../src/nest/reservations/reservations.mcp.ts | 314 +++++++++- .../src/nest/settings/settings.controller.ts | 29 +- server/src/nest/settings/settings.service.ts | 17 + server/tests/e2e/collections.e2e.test.ts | 28 + server/tests/e2e/settings.e2e.test.ts | 71 +++ server/tests/integration/atlas.test.ts | 68 +++ .../unit/mcp/tools-atlas-expanded.test.ts | 16 + server/tests/unit/mcp/tools-atlas.test.ts | 30 + .../tests/unit/mcp/tools-transports.test.ts | 565 +++++++++++++++++- .../nest/accommodations.controller.test.ts | 18 +- .../unit/nest/accommodations.service.test.ts | 60 +- .../tests/unit/nest/atlas.controller.test.ts | 21 + server/tests/unit/nest/atlas.service.test.ts | 119 +++- .../unit/nest/budget.service.calc.test.ts | 120 +++- .../tests/unit/nest/budget.service.db.test.ts | 45 ++ .../tests/unit/nest/calendar.service.test.ts | 91 +++ .../unit/nest/collections.service.test.ts | 19 + .../llm-parse/llm-config.resolver.test.ts | 81 ++- .../plugins/addon-domains-errors.rpc.test.ts | 9 + .../unit/reservations/bookings.rpc.test.ts | 7 +- .../src/collection/collection.schema.spec.ts | 22 + shared/src/collection/collection.schema.ts | 4 + shared/src/i18n/ar/atlas.ts | 1 + shared/src/i18n/ar/settings.ts | 4 +- shared/src/i18n/ar/vacay.ts | 2 +- shared/src/i18n/br/atlas.ts | 1 + shared/src/i18n/br/settings.ts | 4 +- shared/src/i18n/br/vacay.ts | 2 +- shared/src/i18n/ca/atlas.ts | 1 + shared/src/i18n/ca/settings.ts | 2 + shared/src/i18n/ca/vacay.ts | 2 +- shared/src/i18n/cs/atlas.ts | 1 + shared/src/i18n/cs/settings.ts | 4 +- shared/src/i18n/cs/vacay.ts | 2 +- shared/src/i18n/de/atlas.ts | 1 + shared/src/i18n/de/settings.ts | 4 +- shared/src/i18n/de/vacay.ts | 2 +- shared/src/i18n/en/atlas.ts | 1 + shared/src/i18n/en/settings.ts | 4 +- shared/src/i18n/en/vacay.ts | 2 +- shared/src/i18n/es/atlas.ts | 1 + shared/src/i18n/es/settings.ts | 4 +- shared/src/i18n/es/vacay.ts | 2 +- shared/src/i18n/fr/atlas.ts | 1 + shared/src/i18n/fr/settings.ts | 4 +- shared/src/i18n/fr/vacay.ts | 2 +- shared/src/i18n/gr/atlas.ts | 1 + shared/src/i18n/gr/settings.ts | 4 +- shared/src/i18n/gr/vacay.ts | 2 +- shared/src/i18n/hu/atlas.ts | 1 + shared/src/i18n/hu/settings.ts | 4 +- shared/src/i18n/hu/vacay.ts | 2 +- shared/src/i18n/id/atlas.ts | 1 + shared/src/i18n/id/settings.ts | 4 +- shared/src/i18n/id/vacay.ts | 2 +- shared/src/i18n/it/atlas.ts | 1 + shared/src/i18n/it/settings.ts | 4 +- shared/src/i18n/it/vacay.ts | 2 +- shared/src/i18n/ja/atlas.ts | 1 + shared/src/i18n/ja/settings.ts | 4 +- shared/src/i18n/ja/vacay.ts | 2 +- shared/src/i18n/ko/atlas.ts | 1 + shared/src/i18n/ko/settings.ts | 4 +- shared/src/i18n/ko/vacay.ts | 2 +- shared/src/i18n/nl/atlas.ts | 1 + shared/src/i18n/nl/settings.ts | 4 +- shared/src/i18n/nl/vacay.ts | 2 +- shared/src/i18n/pl/atlas.ts | 1 + shared/src/i18n/pl/settings.ts | 4 +- shared/src/i18n/pl/vacay.ts | 2 +- shared/src/i18n/ru/atlas.ts | 1 + shared/src/i18n/ru/settings.ts | 4 +- shared/src/i18n/ru/vacay.ts | 2 +- shared/src/i18n/sv/atlas.ts | 1 + shared/src/i18n/sv/settings.ts | 4 +- shared/src/i18n/sv/vacay.ts | 2 +- shared/src/i18n/tr/atlas.ts | 1 + shared/src/i18n/tr/settings.ts | 4 +- shared/src/i18n/tr/vacay.ts | 2 +- shared/src/i18n/uk/atlas.ts | 1 + shared/src/i18n/uk/settings.ts | 4 +- shared/src/i18n/uk/vacay.ts | 2 +- shared/src/i18n/vi/atlas.ts | 1 + shared/src/i18n/vi/settings.ts | 4 +- shared/src/i18n/vi/vacay.ts | 2 +- shared/src/i18n/zh-TW/atlas.ts | 1 + shared/src/i18n/zh-TW/settings.ts | 4 +- shared/src/i18n/zh-TW/vacay.ts | 2 +- shared/src/i18n/zh/atlas.ts | 1 + shared/src/i18n/zh/settings.ts | 5 +- shared/src/i18n/zh/vacay.ts | 2 +- .../reservation/reservation.schema.spec.ts | 50 ++ shared/src/reservation/reservation.schema.ts | 46 ++ wiki/MCP-Addon-Tools.md | 2 +- 176 files changed, 4988 insertions(+), 690 deletions(-) create mode 100644 client/e2e/urlbar-scroll-1809.spec.ts create mode 100644 client/src/utils/bodyScrollLock.test.ts create mode 100644 client/src/utils/bodyScrollLock.ts diff --git a/client/e2e/urlbar-scroll-1809.spec.ts b/client/e2e/urlbar-scroll-1809.spec.ts new file mode 100644 index 000000000..917748f76 --- /dev/null +++ b/client/e2e/urlbar-scroll-1809.spec.ts @@ -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) +}) diff --git a/client/src/components/Budget/CostsPanel.helpers.test.ts b/client/src/components/Budget/CostsPanel.helpers.test.ts index ec2d15cb6..31c97c102 100644 --- a/client/src/components/Budget/CostsPanel.helpers.test.ts +++ b/client/src/components/Budget/CostsPanel.helpers.test.ts @@ -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) => + 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 }) + }) +}) diff --git a/client/src/components/Budget/CostsPanel.helpers.ts b/client/src/components/Budget/CostsPanel.helpers.ts index 74e6d631e..8bc566144 100644 --- a/client/src/components/Budget/CostsPanel.helpers.ts +++ b/client/src/components/Budget/CostsPanel.helpers.ts @@ -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; total: number } { const shares: Record = {} 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] diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 037d62a37..bba2289e5 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -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 }) }) }) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 2a210162a..4bca99248 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -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, diff --git a/client/src/components/Collections/AddPlaceToCollectionModal.test.tsx b/client/src/components/Collections/AddPlaceToCollectionModal.test.tsx index 7658da9d9..44282d5b3 100644 --- a/client/src/components/Collections/AddPlaceToCollectionModal.test.tsx +++ b/client/src/components/Collections/AddPlaceToCollectionModal.test.tsx @@ -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() + }) }) diff --git a/client/src/components/Collections/AddPlaceToCollectionModal.tsx b/client/src/components/Collections/AddPlaceToCollectionModal.tsx index 0e53dc78c..da2117dc8 100644 --- a/client/src/components/Collections/AddPlaceToCollectionModal.tsx +++ b/client/src/components/Collections/AddPlaceToCollectionModal.tsx @@ -42,6 +42,9 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec const [query, setQuery] = useState('') const [results, setResults] = useState([]) 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(null) const [name, setName] = useState('') @@ -56,16 +59,20 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec const [status, setStatus] = useState('idea') const [saving, setSaving] = useState(false) const descRef = useRef(null) + const searchRef = useRef(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) => 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 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 } >
- {/* Search — picking a result fills the location below */} -
-
-
- - 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" - /> -
- -
- {results.length > 0 && ( -
-
- {t('common.search')} - + {/* 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. */} +
+
+
+
+ + { 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" + />
- {results.map((r, i) => ( - - ))} +
- )} + {(results.length > 0 || noResults) && ( +
+
+ {t('common.search')} + +
+ {noResults ? ( +
{t('planner.noPlacesFound')}
+ ) : results.map((r, i) => ( + + ))} +
+ )} +
{/* Name */} diff --git a/client/src/components/Collections/CollectionPlaceDetail.test.tsx b/client/src/components/Collections/CollectionPlaceDetail.test.tsx index c57a2f933..a4ea825f8 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.test.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.test.tsx @@ -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'); + }); +}); diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index 2684b5cc0..7a3166b50 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -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 + 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 /** Upload a custom cover image (#1136); enables the cover change/remove controls. */ onUploadImage?: (file: File) => Promise onCopyToTrip: () => void @@ -83,6 +83,7 @@ export default function CollectionPlaceDetail({ const [description, setDescription] = useState(place.description ?? '') const [links, setLinks] = useState(place.links ?? []) const [labelIds, setLabelIds] = useState(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) => 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) => { 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({ })}
+ {/* Address (#1870): free text, the coordinates stay separate */} +
+
{t('places.formAddress')}
+ setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className="col-detail-input" /> +
{/* Coordinates */}
{t('collections.coordinates')}
diff --git a/client/src/components/PDF/JourneyBookPDF.test.tsx b/client/src/components/PDF/JourneyBookPDF.test.tsx index 8d7a994d5..d71bbcbca 100644 --- a/client/src/components/PDF/JourneyBookPDF.test.tsx +++ b/client/src/components/PDF/JourneyBookPDF.test.tsx @@ -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 { + 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(''); + 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(' { + // 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(/
[\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"'); + }); }); diff --git a/client/src/components/PDF/JourneyBookPDF.tsx b/client/src/components/PDF/JourneyBookPDF.tsx index 9aae33fec..a7f267eb4 100644 --- a/client/src/components/PDF/JourneyBookPDF.tsx +++ b/client/src/components/PDF/JourneyBookPDF.tsx @@ -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 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 { @@ -44,7 +46,7 @@ function groupByDate(entries: JourneyEntry[]): Map { 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 `
- ${pros.length > 0 ? `
Loved it
    ${pros.map(p => `
  • ${esc(p)}
  • `).join('')}
` : ''} - ${cons.length > 0 ? `
Could be better
    ${cons.map(c => `
  • ${esc(c)}
  • `).join('')}
` : ''} + ${pros.length > 0 ? `
${esc(tr('journey.verdict.lovedIt'))}
    ${pros.map(p => `
  • ${esc(p)}
  • `).join('')}
` : ''} + ${cons.length > 0 ? `
${esc(tr('journey.verdict.couldBeBetter'))}
    ${cons.map(c => `
  • ${esc(c)}
  • `).join('')}
` : ''}
` } +function chip(icon: LucideIcon, label: string, bg: string, color: string): string { + const svg = renderIconMarkup(createElement(icon, { size: 12, strokeWidth: 2, color })) + return `${svg}${esc(label)}` +} + +/** + * 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 `
${chips.join('')}
` +} + function renderPhotoBlock(photos: JourneyPhoto[]): string { if (photos.length === 0) return '' if (photos.length === 1) { @@ -75,7 +97,16 @@ function renderPhotoBlock(photos: JourneyPhoto[]): string {
` } -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 - ? `
Day ${di + 1} · ${fmtDate(date)}
` + ? `
${esc(tr('journey.detail.day', { number: di + 1 }))} · ${esc(fmtDate(date, loc))}
` : '' // Photo block const photoHtml = renderPhotoBlock(photos) // Pros/cons - const prosconsHtml = renderProscons(entry) + const prosconsHtml = renderProscons(entry, tr) // Story (markdown) const storyHtml = entry.story ? `
${md(entry.story)}
` : '' @@ -114,6 +145,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) { ${photoHtml}
${meta ? `` : ''} + ${renderMoodWeather(entry, tr)} ${entry.title ? `

${esc(entry.title)}

` : ''} ${storyHtml} ${prosconsHtml} @@ -126,11 +158,17 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) { const totalPages = pageNum + 1 // +1 for closing page const html = ` - + + + -${esc(journey.title)} — Journey Book +${esc(journey.title)} — ${esc(tr('journey.pdf.journeyBook'))}