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 ( + + )} + {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)