mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat: six community requests — import dedup, accommodations in the feed, OAuth refresh race, visited-from-trip, journal jumps, phone packing rows (#1915)
* fix(places): match imports on the provider id so renamed places are not duplicated Re-importing a Google Maps list duplicated every place the user had renamed. buildDedupSet only ever collected names and, for unnamed places, coordinates, and isPlaceDuplicate returns on the name before it reaches the coordinate fallback, so a renamed place was findable by neither. The ids were stored all along, and even backfilled onto matched places, they were simply never read. Match on google_place_id / google_ftid / osm_id first, keeping name and coordinates in their old roles below it. Widening the coordinate check instead would have merged the restaurant and the bar in the same building. The collection copy carried a hand-mirrored port of the same predicate; it now uses the shared helpers and gets the fix with them. Closes #1550 * feat(calendar): put accommodations into the subscribed feed as all-day stays A hotel keeps its dates on the linked stay, not on the reservation: the booking form writes reservation_time = NULL for type 'hotel', and day_accommodations carries start day, end day and the check-in/out clock. The feed only ever read the reservation, so a stay showed up once on the arrival day at best, and not at all when it came from the booking form. Join the stay in and emit the reservation as an all-day range over every night, which also keeps it correct when days are reordered later — the dates now come from the day rows rather than from the copy stamped at creation time. Check-in and check-out become their own timed events when the stay records a clock, because an all-day event cannot carry the time you actually need reminding of. The check-in window, when set, becomes that event's DTEND. Closes #1586 * fix(oauth): treat a concurrent refresh as a race, not as a stolen token Refresh rotation is a race by construction. Several MCP clients share one refresh token, both post it within the same second, and the loser was read as a replay: chain revoked, MCP sessions torn down, browser window asking to log in again. That is the daily re-authentication people were seeing, not a short lifetime — access tokens last an hour and refresh tokens roll for thirty days. A revoked refresh token now only counts as a replay once it is outside a 30-second grace window, and only when the rotation that revoked it left a successor still alive. Inside the window the caller gets its own pair branched off the same parent, so both clients walk away with a working token. Explicit revocation leaves no live successor and a chain killed by a real replay has none either, so neither can slip through the window. Closes #1007 * feat(collections): mark a place visited from the trip it belongs to A place saved in several lists had to be opened in each of them to say you had been there, which is the wrong way round: you find out you have been somewhere while looking at the trip, not while browsing the library. The save picker now shows each list's own status and cycles it in place, with one action for "visited, wherever this is saved". The planner's selection bar and the mobile bulk toolbar carry the same thing for a whole selection — select all, one tap, done. Matching happens on the server with the signals findMembership already uses plus the source link a place saved from this trip carries, so a copy renamed inside a list is still found. Lists you may only read are skipped rather than refused; a shared list you cannot edit should not stop you marking your own. Closes #1469 * feat(journey): jump to the top or back to the last entry in a long journal Everything that adds to a journal sits at the top — the entry button, the gallery upload — and where you left off sits at the bottom, so adding one photo to a long journey meant scrolling the whole way twice. Two buttons float over the middle of the feed, clear of the Add Entry column on one side and the reorder arrows on the other, each appearing only when there is more than a screenful to travel in that direction. They live in a zero-height sticky box so they take no layout space and never push the timeline around. While in the header, the status pill also picks up the height of the round buttons beside it. Closes #1088 * fix(packing): give the item name the row back on phones An item name had roughly a third of the row to itself: a spelled-out sharing sentence, an empty weight column and two round action buttons took the rest, so anything longer than a word was cut off. Sharing is now the icon it always was next to a label rather than a sentence in the layout, an unset weight prints nothing instead of a dash, and edit/delete sit behind one overflow button that opens the same inline menu the bag picker already uses. The to-do list needed none of this — it wraps its chips onto a second line already. Closes #1525 * docs(wiki): cover the six community changes - Calendar feeds: accommodations as an all-day range plus check-in/check-out - Collections: setting the visited status from a trip, and how places are matched - Journey: the jump buttons on a long journal - Packing: what the phone row keeps and what moved behind the overflow button - Places: re-importing a list no longer duplicates renamed places - MCP setup: the rotation race no longer ends an OAuth session * test(collections): assert the stored status instead of a private method tests/ sits outside the build tsconfig, so reaching into getPlaceById only showed up in the dedicated typecheck step. Reading the row is the better assertion anyway — it pins what was persisted, not what a getter returns.
This commit is contained in:
@@ -17,6 +17,7 @@ import type {
|
||||
CollectionInviteActionRequest,
|
||||
CollectionInviteCancelRequest,
|
||||
CollectionStatus,
|
||||
CollectionSetStatusManyResponse,
|
||||
Collection,
|
||||
CollectionPlace,
|
||||
CollectionLabel,
|
||||
@@ -77,6 +78,10 @@ export const collectionsApi = {
|
||||
postMultipart(`${base}/places/${pid}/image`, formData),
|
||||
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
|
||||
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
|
||||
setStatusMany: (ids: number[], status: CollectionStatus): Promise<CollectionSetStatusManyResponse> =>
|
||||
ax.post(`${base}/places/status-many`, { ids, status }).then((r: AxiosResponse) => r.data),
|
||||
setStatusFromTrip: (tripId: number, placeIds: number[], status: CollectionStatus): Promise<CollectionSetStatusManyResponse> =>
|
||||
ax.post(`${base}/places/status-from-trip`, { trip_id: tripId, place_ids: placeIds, status }).then((r: AxiosResponse) => r.data),
|
||||
ratePlace: (pid: number, rating: number | null): Promise<CollectionPlace> =>
|
||||
rating === null
|
||||
? ax.delete(`${base}/places/${pid}/rating`).then((r: AxiosResponse) => r.data)
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('MSaveToCollectionSheet', () => {
|
||||
it('FE-COMP-MSAVESHEET-006: lists already holding the place are marked saved', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
|
||||
});
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
@@ -167,7 +167,7 @@ describe('MSaveToCollectionSheet', () => {
|
||||
it('FE-COMP-MSAVESHEET-010: tapping a saved list removes that place instead', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
|
||||
});
|
||||
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
|
||||
openFor();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Bookmark, BookmarkCheck, Check, Loader2, Plus, X } from 'lucide-react'
|
||||
import { Bookmark, BookmarkCheck, Check, CheckCircle2, Loader2, Plus, X } from 'lucide-react'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import MIconBtn from '../../mobile/components/MIconBtn'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -8,7 +8,8 @@ import { useTranslation } from '../../i18n'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import type { Collection, CollectionMembership } from '@trek/shared'
|
||||
import { STATUS_META, nextStatus } from '../../pages/collections/collectionsModel'
|
||||
import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Mobile counterpart of SaveToCollectionModal — the same store-driven list
|
||||
@@ -71,12 +72,44 @@ export default function MSaveToCollectionSheet() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [target])
|
||||
|
||||
const savedByCollection = new Map<number, number>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id)
|
||||
const savedByCollection = new Map<number, CollectionMembership['lists'][number]>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l)
|
||||
|
||||
/** Lists holding this place that the viewer may edit and that are not visited yet. */
|
||||
const unvisited = (membership?.lists ?? []).filter(l => l.can_edit && l.status !== 'visited')
|
||||
|
||||
const handleStatus = async (entry: CollectionMembership['lists'][number], next: CollectionStatus) => {
|
||||
if (busyId != null) return
|
||||
setBusyId(entry.collection_id)
|
||||
try {
|
||||
await collectionsApi.setStatus(entry.place_id, next)
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisitedEverywhere = async () => {
|
||||
if (busyId != null || unvisited.length === 0) return
|
||||
setBusyId(-1)
|
||||
try {
|
||||
const { updated } = await collectionsApi.setStatusMany(unvisited.map(l => l.place_id), 'visited')
|
||||
toast.success(t('collections.markedVisited', { count: updated }))
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (list: Collection) => {
|
||||
if (busyId != null || !target) return
|
||||
const savedPlaceId = savedByCollection.get(list.id)
|
||||
const savedPlaceId = savedByCollection.get(list.id)?.place_id
|
||||
setBusyId(list.id)
|
||||
try {
|
||||
if (savedPlaceId != null) {
|
||||
@@ -156,8 +189,11 @@ export default function MSaveToCollectionSheet() {
|
||||
</div>
|
||||
) : (
|
||||
lists.map(list => {
|
||||
const saved = savedByCollection.has(list.id)
|
||||
const entry = savedByCollection.get(list.id)
|
||||
const saved = !!entry
|
||||
const busy = busyId === list.id
|
||||
const statusMeta = entry ? STATUS_META[entry.status] : null
|
||||
const StatusIcon = statusMeta?.icon
|
||||
return (
|
||||
<button
|
||||
key={list.id}
|
||||
@@ -178,9 +214,27 @@ export default function MSaveToCollectionSheet() {
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{list.name}</span>
|
||||
<span className="mt-px block font-geist text-[0.65625rem] text-m-muted">
|
||||
{t('collections.placeCount', { count: list.place_count ?? 0 })}
|
||||
</span>
|
||||
{/* Saved lists show their own status here instead of the place
|
||||
count — that number matters when picking a list, the status
|
||||
matters once the place is in it (#1469). */}
|
||||
{entry && statusMeta && StatusIcon ? (
|
||||
<span
|
||||
role={entry.can_edit ? 'button' : undefined}
|
||||
tabIndex={entry.can_edit ? 0 : undefined}
|
||||
aria-label={t(statusMeta.labelKey)}
|
||||
onClick={entry.can_edit ? e => { e.preventDefault(); e.stopPropagation(); void handleStatus(entry, nextStatus(entry.status)) } : undefined}
|
||||
onKeyDown={entry.can_edit ? e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); void handleStatus(entry, nextStatus(entry.status)) } } : undefined}
|
||||
className="mt-[3px] inline-flex items-center gap-1 rounded-full bg-[color:var(--m-inner)] px-2 py-[3px] font-geist text-[0.65625rem] font-semibold"
|
||||
style={{ color: statusMeta.color }}
|
||||
>
|
||||
<StatusIcon size={11} strokeWidth={2.4} />
|
||||
{t(statusMeta.labelKey)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-px block font-geist text-[0.65625rem] text-m-muted">
|
||||
{t('collections.placeCount', { count: list.place_count ?? 0 })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{list.is_owner === false && (
|
||||
<span className="flex-none font-geist text-[0.5625rem] font-bold uppercase tracking-[.05em] text-m-faint">
|
||||
@@ -200,6 +254,22 @@ export default function MSaveToCollectionSheet() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unvisited.length > 0 && (
|
||||
<div className="flex-none px-[14px] pb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVisitedEverywhere}
|
||||
disabled={busyId != null}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[10px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-60"
|
||||
>
|
||||
{busyId === -1
|
||||
? <Loader2 size={14} className="animate-spin" />
|
||||
: <CheckCircle2 size={14} strokeWidth={2.2} />}
|
||||
{unvisited.length > 1 ? t('collections.markVisitedAll') : t('collections.markVisited')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lists.length > 0 && (
|
||||
<div className="flex flex-none items-center justify-between gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] py-3">
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-SAVETOCOL-001 to FE-COMP-SAVETOCOL-016
|
||||
// FE-COMP-SAVETOCOL-001 to FE-COMP-SAVETOCOL-020
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
|
||||
@@ -120,7 +120,7 @@ describe('SaveToCollectionModal', () => {
|
||||
it('FE-COMP-SAVETOCOL-006: lists already holding the place are marked saved', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
|
||||
});
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
@@ -194,7 +194,7 @@ describe('SaveToCollectionModal', () => {
|
||||
it('FE-COMP-SAVETOCOL-011: picking a saved list removes that place instead', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
|
||||
});
|
||||
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
|
||||
const save = vi.spyOn(collectionsApi, 'savePlace');
|
||||
@@ -263,6 +263,65 @@ describe('SaveToCollectionModal', () => {
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Per-list status + "visited everywhere" (#1469) ────────────────────────
|
||||
|
||||
const savedIn = (over: Partial<CollectionMembership['lists'][number]> = {}): CollectionMembership => ({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true, ...over }],
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-017: a saved list shows its own status and one tap cycles it', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(savedIn());
|
||||
const setStatus = vi.spyOn(collectionsApi, 'setStatus').mockResolvedValue({} as never);
|
||||
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
const badge = await screen.findByRole('button', { name: 'Want to go' });
|
||||
fireEvent.click(badge);
|
||||
|
||||
// want → visited, and the row toggle must NOT have fired underneath it.
|
||||
await waitFor(() => expect(setStatus).toHaveBeenCalledWith(900, 'visited'));
|
||||
expect(del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-018: the header action marks every list holding the place visited', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [
|
||||
{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true },
|
||||
{ collection_id: 2, name: 'Wishlist', place_id: 901, status: 'idea', can_edit: true },
|
||||
],
|
||||
});
|
||||
const many = vi.spyOn(collectionsApi, 'setStatusMany').mockResolvedValue({ updated: 2 });
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Visited everywhere' }));
|
||||
await waitFor(() => expect(many).toHaveBeenCalledWith([900, 901], 'visited'));
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-019: the action disappears once every list is visited', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(savedIn({ status: 'visited' }));
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
await screen.findByText('Favorites');
|
||||
expect(screen.queryByRole('button', { name: /Visited everywhere|Mark visited/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-020: a read-only list shows its status but does not offer to change it', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(savedIn({ can_edit: false }));
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
await screen.findByText('Favorites');
|
||||
expect(screen.queryByRole('button', { name: 'Want to go' })).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle('Want to go')).toBeInTheDocument();
|
||||
// Nothing to offer: the only list holding it is one the viewer cannot edit.
|
||||
expect(screen.queryByRole('button', { name: /Visited everywhere|Mark visited/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-014: the footer navigates to the collections page or just closes', async () => {
|
||||
openFor();
|
||||
const { unmount } = render(<SaveToCollectionModal />);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Bookmark, BookmarkCheck, Check, Loader2, Plus } from 'lucide-react'
|
||||
import { Bookmark, BookmarkCheck, Check, CheckCircle2, Loader2, Plus } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import type { Collection, CollectionMembership } from '@trek/shared'
|
||||
import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Globally-mounted list picker for the "Save to Collection" entry points
|
||||
@@ -70,12 +71,44 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
|
||||
if (!target) return null
|
||||
|
||||
const savedByCollection = new Map<number, number>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id)
|
||||
const savedByCollection = new Map<number, CollectionMembership['lists'][number]>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l)
|
||||
|
||||
/** Lists holding this place that the viewer may edit and that are not visited yet. */
|
||||
const unvisited = (membership?.lists ?? []).filter(l => l.can_edit && l.status !== 'visited')
|
||||
|
||||
const handleStatus = async (entry: CollectionMembership['lists'][number], next: CollectionStatus) => {
|
||||
if (busyId != null) return
|
||||
setBusyId(entry.collection_id)
|
||||
try {
|
||||
await collectionsApi.setStatus(entry.place_id, next)
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisitedEverywhere = async () => {
|
||||
if (busyId != null || unvisited.length === 0) return
|
||||
setBusyId(-1)
|
||||
try {
|
||||
const { updated } = await collectionsApi.setStatusMany(unvisited.map(l => l.place_id), 'visited')
|
||||
toast.success(t('collections.markedVisited', { count: updated }))
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (list: Collection) => {
|
||||
if (busyId != null) return
|
||||
const savedPlaceId = savedByCollection.get(list.id)
|
||||
const savedPlaceId = savedByCollection.get(list.id)?.place_id
|
||||
setBusyId(list.id)
|
||||
try {
|
||||
if (savedPlaceId != null) {
|
||||
@@ -140,7 +173,23 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[13px] font-semibold text-content truncate">{target.name}</p>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="flex-1 min-w-0 text-[13px] font-semibold text-content truncate">{target.name}</p>
|
||||
{/* One tap for "I have been here", however many lists hold it (#1469). */}
|
||||
{unvisited.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVisitedEverywhere}
|
||||
disabled={busyId != null}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-edge bg-surface-card text-[11px] font-semibold text-content-secondary hover:bg-surface-hover disabled:opacity-60"
|
||||
>
|
||||
{busyId === -1
|
||||
? <Loader2 size={12} className="animate-spin" />
|
||||
: <CheckCircle2 size={12} strokeWidth={2.2} />}
|
||||
{unvisited.length > 1 ? t('collections.markVisitedAll') : t('collections.markVisited')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-content-faint">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
@@ -162,7 +211,8 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
|
||||
{lists.map(list => {
|
||||
const saved = savedByCollection.has(list.id)
|
||||
const entry = savedByCollection.get(list.id)
|
||||
const saved = !!entry
|
||||
const busy = busyId === list.id
|
||||
return (
|
||||
<button
|
||||
@@ -170,13 +220,24 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
type="button"
|
||||
onClick={() => handleToggle(list)}
|
||||
disabled={busyId != null}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
|
||||
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: list.color || 'var(--accent)' }} />
|
||||
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{list.name}</span>
|
||||
{list.is_owner === false && (
|
||||
<span className="text-[10px] uppercase font-semibold text-content-faint">{t('collections.shared')}</span>
|
||||
)}
|
||||
{/* Per-list status, so one place can be an idea in one list and
|
||||
visited in another. A role=button span, safe to nest here. */}
|
||||
{entry && (
|
||||
<StatusBadge
|
||||
status={entry.status}
|
||||
showLabel={false}
|
||||
size={12}
|
||||
onChange={entry.can_edit ? next => { void handleStatus(entry, next) } : undefined}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<span className={`w-5 h-5 rounded-md flex items-center justify-center shrink-0 ${saved ? 'bg-accent text-accent-text' : 'border border-edge text-transparent'}`}>
|
||||
{busy ? <Loader2 size={13} className="animate-spin text-content-faint" /> : saved ? <BookmarkCheck size={13} /> : <Check size={13} />}
|
||||
</span>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Check, Tag, Trash2, Bookmark } from 'lucide-react'
|
||||
import { Check, CheckCircle2, Loader2, Tag, Trash2, Bookmark } from 'lucide-react'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
|
||||
export function PlacesSelectionBar(S: SidebarState) {
|
||||
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen, collectionsEnabled, setSaveToListOpen } = S
|
||||
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen, collectionsEnabled, setSaveToListOpen, markSelectionVisited, markVisitedBusy } = S
|
||||
return (
|
||||
<div style={{
|
||||
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
|
||||
@@ -68,6 +68,25 @@ export function PlacesSelectionBar(S: SidebarState) {
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{collectionsEnabled && (
|
||||
<Tooltip label={t('collections.markVisitedSelection')} placement="bottom">
|
||||
<button
|
||||
onClick={() => { if (selectedIds.size === 0) return; void markSelectionVisited() }}
|
||||
disabled={selectedIds.size === 0 || markVisitedBusy}
|
||||
aria-label={t('collections.markVisitedSelection')}
|
||||
className={selectedIds.size > 0 ? 'bg-transparent text-content-muted' : 'bg-transparent text-content-faint'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 24, height: 24, borderRadius: 6, border: 'none',
|
||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
|
||||
}}
|
||||
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
{markVisitedBusy ? <Loader2 size={13} className="animate-spin" /> : <CheckCircle2 size={13} strokeWidth={2} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t('places.deleteSelected')} placement="bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useContextMenu } from '../shared/ContextMenu'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -157,8 +158,32 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
|
||||
const [saveToListOpen, setSaveToListOpen] = useState(false)
|
||||
|
||||
const [markVisitedBusy, setMarkVisitedBusy] = useState(false)
|
||||
|
||||
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
|
||||
|
||||
/**
|
||||
* "I have been to these" for the selection, applied wherever the places are
|
||||
* saved in the library (#1469). The server does the matching, so a place saved
|
||||
* under a different name in a list is still found.
|
||||
*/
|
||||
const markSelectionVisited = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0 || markVisitedBusy) return
|
||||
setMarkVisitedBusy(true)
|
||||
try {
|
||||
const { updated, places: matchedPlaces } = await collectionsApi.setStatusFromTrip(props.tripId, ids, 'visited')
|
||||
if (updated === 0) toast.info(t('collections.markVisitedNone'))
|
||||
else toast.success(t('collections.markedVisitedTrip', { count: matchedPlaces ?? 0 }))
|
||||
exitSelectMode()
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setMarkVisitedBusy(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedIds, markVisitedBusy, props.tripId, t])
|
||||
|
||||
// Auto-exit when all selected places have been removed from the store (e.g. after bulk delete)
|
||||
useEffect(() => {
|
||||
if (!selectMode || selectedIds.size === 0) return
|
||||
@@ -275,6 +300,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||
categoryPickerOpen, setCategoryPickerOpen,
|
||||
saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId,
|
||||
markSelectionVisited, markVisitedBusy,
|
||||
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
|
||||
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
|
||||
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Bookmark, Check, CheckCheck, ListChecks, MapPin, MoreHorizontal, Plus,
|
||||
Bookmark, Check, CheckCheck, CheckCircle2, ListChecks, Loader2, MapPin, MoreHorizontal, Plus,
|
||||
SlidersHorizontal, Tag, Trash2, X,
|
||||
} from 'lucide-react'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { useAddonStore } from '../../../../store/addonStore'
|
||||
import { useToast } from '../../../../components/shared/Toast'
|
||||
import { collectionsApi } from '../../../../api/collections'
|
||||
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
|
||||
import { resolveTrackColor } from '../../../../components/Map/trackColors'
|
||||
@@ -43,6 +45,8 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
|
||||
const [saveToListOpen, setSaveToListOpen] = useState(false)
|
||||
const [markVisitedBusy, setMarkVisitedBusy] = useState(false)
|
||||
const toast = useToast()
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
||||
|
||||
// Entering the browser from the edit segment starts on the unplanned pool.
|
||||
@@ -85,6 +89,23 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
return next
|
||||
})
|
||||
|
||||
/** Mark the selection visited in every list it is saved to (#1469). */
|
||||
const markSelectionVisited = async () => {
|
||||
const ids = [...selectedIds]
|
||||
if (ids.length === 0 || markVisitedBusy) return
|
||||
setMarkVisitedBusy(true)
|
||||
try {
|
||||
const { updated, places: matched } = await collectionsApi.setStatusFromTrip(trip.id, ids, 'visited')
|
||||
if (updated === 0) toast.info(t('collections.markVisitedNone'))
|
||||
else toast.success(t('collections.markedVisitedTrip', { count: matched ?? 0 }))
|
||||
exitSelectMode()
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setMarkVisitedBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Compare the ids, not just the counts: a place removed remotely while another
|
||||
// one is selected keeps the sizes equal without the sets matching.
|
||||
const allSelected = filtered.length > 0 && filtered.every(p => selectedIds.has(p.id))
|
||||
@@ -223,6 +244,15 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
<Bookmark size={14} strokeWidth={2} />
|
||||
</BulkBtn>
|
||||
)}
|
||||
{collectionsEnabled && (
|
||||
<BulkBtn
|
||||
label={t('collections.markVisitedSelection')}
|
||||
disabled={selectedIds.size === 0 || markVisitedBusy}
|
||||
onClick={markSelectionVisited}
|
||||
>
|
||||
{markVisitedBusy ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle2 size={14} strokeWidth={2} />}
|
||||
</BulkBtn>
|
||||
)}
|
||||
<BulkBtn label={t('places.deleteSelected')} disabled={selectedIds.size === 0} onClick={() => setConfirmDeleteOpen(true)}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</BulkBtn>
|
||||
|
||||
@@ -767,6 +767,7 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
|
||||
}) {
|
||||
const { t, tripId, tripActions } = planner
|
||||
const [bagPickerOpen, setBagPickerOpen] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [creatingBag, setCreatingBag] = useState(false)
|
||||
const [newBagName, setNewBagName] = useState('')
|
||||
|
||||
@@ -821,16 +822,26 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
|
||||
{isPlaceholder ? t('packing.addItemPlaceholder') : item.name}
|
||||
</span>
|
||||
|
||||
{/* Sharing state is an icon, not a sentence (#1525): "Taken care of by
|
||||
Alexander" spelled out took a third of the row away from the item
|
||||
name. The full wording lives on the pill as its label. */}
|
||||
{badgeSharedToMe && (
|
||||
<span className="flex flex-none items-center gap-1 whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.59375rem] font-bold text-m-muted">
|
||||
<HandHelping size={9} strokeWidth={2.2} />
|
||||
{t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
<span
|
||||
className="flex h-5 w-5 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted"
|
||||
title={t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
aria-label={t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
>
|
||||
<HandHelping size={11} strokeWidth={2.2} />
|
||||
</span>
|
||||
)}
|
||||
{!badgeSharedToMe && badgeSharedByMe && (
|
||||
<span className="flex flex-none items-center gap-1 whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.59375rem] font-bold text-m-muted">
|
||||
<UserRound size={9} strokeWidth={2.2} />
|
||||
{t('packing.sharedWithCount', { count: recipients.length })}
|
||||
<span
|
||||
className="flex flex-none items-center gap-[2px] rounded-full bg-[color:var(--m-ic)] px-[5px] py-[3px] font-geist text-[0.59375rem] font-bold text-m-muted"
|
||||
title={t('packing.sharedWithCount', { count: recipients.length })}
|
||||
aria-label={t('packing.sharedWithCount', { count: recipients.length })}
|
||||
>
|
||||
<UserRound size={10} strokeWidth={2.2} />
|
||||
{recipients.length}
|
||||
</span>
|
||||
)}
|
||||
{!badgeSharedToMe && !badgeSharedByMe && badgeBroughtBy && (
|
||||
@@ -850,16 +861,18 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
|
||||
</span>
|
||||
)}
|
||||
|
||||
{editMode && bagTrackingEnabled && (
|
||||
{/* A weight nobody entered is not worth a column: "— g" on every row
|
||||
cost as much width as a real value (#1525). */}
|
||||
{editMode && bagTrackingEnabled && item.weight_grams != null && (
|
||||
<span className="flex-none font-geist text-[0.625rem] font-semibold tabular-nums text-m-faint">
|
||||
{item.weight_grams != null ? formatWeight(item.weight_grams) : '— g'}
|
||||
{formatWeight(item.weight_grams)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{bagTrackingEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBagPickerOpen(v => !v)}
|
||||
onClick={() => { setMenuOpen(false); setBagPickerOpen(v => !v) }}
|
||||
aria-label={t('packing.bags')}
|
||||
className="h-[18px] w-[18px] flex-none rounded-full"
|
||||
style={bag
|
||||
@@ -868,18 +881,43 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit and delete moved behind one button (#1525): two round targets
|
||||
plus their gaps were the widest thing on an edit-mode row, and
|
||||
neither is used often enough to earn a permanent seat. */}
|
||||
{editMode && canEdit && (
|
||||
<>
|
||||
<button type="button" onClick={onEdit} aria-label={t('common.edit')} className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
|
||||
<Pencil size={11} strokeWidth={2} />
|
||||
</button>
|
||||
<button type="button" onClick={onDelete} aria-label={t('common.delete')} className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
|
||||
<Trash2 size={11} strokeWidth={2} />
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setBagPickerOpen(false); setMenuOpen(v => !v) }}
|
||||
aria-label={t('mobileTrip.more')}
|
||||
aria-expanded={menuOpen}
|
||||
className={`flex h-7 w-7 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] ${menuOpen ? 'text-m-ink' : 'text-m-muted'}`}
|
||||
>
|
||||
<MoreHorizontal size={13} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{menuOpen && editMode && canEdit && (
|
||||
<div className="mb-[6px] ml-[28px] overflow-hidden rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)] backdrop-blur-[24px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMenuOpen(false); onEdit() }}
|
||||
className="flex w-full items-center gap-[9px] border-b border-[color:var(--m-rowbr)] px-3 py-2 text-left text-[0.75rem] font-medium text-m-ink"
|
||||
>
|
||||
<Pencil size={12} strokeWidth={2} />
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMenuOpen(false); onDelete() }}
|
||||
className="flex w-full items-center gap-[9px] px-3 py-2 text-left text-[0.75rem] font-medium text-[color:var(--m-st-danger)]"
|
||||
>
|
||||
<Trash2 size={12} strokeWidth={2} />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bagPickerOpen && bagTrackingEnabled && (
|
||||
<div className="mb-[6px] ml-[28px] overflow-hidden rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)] backdrop-blur-[24px]">
|
||||
<button type="button" onClick={() => assignBag(null)} className="flex w-full items-center gap-[9px] border-b border-[color:var(--m-rowbr)] px-3 py-2 text-left text-[0.75rem] font-medium text-m-muted">
|
||||
|
||||
@@ -45,6 +45,7 @@ function JourneyDetailPageDesktop() {
|
||||
hideSkeletons, setHideSkeletons,
|
||||
mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick,
|
||||
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
|
||||
feedEdge, scrollFeedTo,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
|
||||
} = useJourneyDetail()
|
||||
|
||||
@@ -210,12 +211,12 @@ function JourneyDetailPageDesktop() {
|
||||
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{lifecycle !== 'live' && lifecycle !== 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<div className="inline-flex h-[34px] items-center gap-1.5 px-3.5 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle === 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<div className="inline-flex h-[34px] items-center gap-1.5 px-3.5 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t('journey.status.archived')}
|
||||
</div>
|
||||
)}
|
||||
@@ -427,6 +428,41 @@ function JourneyDetailPageDesktop() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Jump to the top (where adding lives) and back to the last entry
|
||||
(where reading left off) — #1088. Centred over the feed and
|
||||
clear of both edges: the right one belongs to the Add Entry
|
||||
buttons, the left to the reorder arrows. Zero-height sticky box
|
||||
so it rides the scroll without taking layout space, and each
|
||||
half appears only when there is somewhere to go. */}
|
||||
{!isMobile && (!feedEdge.atTop || !feedEdge.atBottom) && (
|
||||
<div className="sticky bottom-0 z-20 h-0 pointer-events-none">
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-1.5 pointer-events-auto">
|
||||
{!feedEdge.atTop && (
|
||||
<button
|
||||
onClick={() => scrollFeedTo('top')}
|
||||
aria-label={t('journey.detail.jumpToTop')}
|
||||
title={t('journey.detail.jumpToTop')}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center shadow-md transition-transform hover:-translate-y-0.5"
|
||||
style={{ background: 'var(--vg-surf)', border: '1px solid var(--vg-line)', color: 'var(--vg-ink)' }}
|
||||
>
|
||||
<ChevronUp size={16} strokeWidth={2.4} />
|
||||
</button>
|
||||
)}
|
||||
{!feedEdge.atBottom && (
|
||||
<button
|
||||
onClick={() => scrollFeedTo('bottom')}
|
||||
aria-label={t('journey.detail.jumpToLast')}
|
||||
title={t('journey.detail.jumpToLast')}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center shadow-md transition-transform hover:translate-y-0.5"
|
||||
style={{ background: 'var(--vg-surf)', border: '1px solid var(--vg-line)', color: 'var(--vg-ink)' }}
|
||||
>
|
||||
<ChevronDown size={16} strokeWidth={2.4} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -105,6 +105,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
|
||||
mapRef: { current: null }, fullMapRef: { current: null },
|
||||
activeLocationId: null, handleMarkerClick: vi.fn(), handleLocationClick: vi.fn(),
|
||||
mapEntries: [], sidebarMapItems: [], tripDates: new Set<string>(), isMobile: false,
|
||||
feedEdge: { atTop: true, atBottom: true }, scrollFeedTo: vi.fn(),
|
||||
loadJourney: vi.fn(), updateEntry: vi.fn(async () => {}), deleteEntry: vi.fn(async () => {}),
|
||||
reorderEntries: vi.fn(async () => {}), uploadPhotos: vi.fn(async () => ({ succeeded: [], failed: [] })),
|
||||
deletePhoto: vi.fn(async () => {}),
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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<string>;
|
||||
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<number>();
|
||||
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<number>();
|
||||
const placesWithMatch = new Set<number>();
|
||||
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',
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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}$/;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -118,17 +118,48 @@ export function reclaimPhotoCache(cache: PlacePhotoCacheService, googlePlaceId:
|
||||
export interface DedupSet {
|
||||
names: Set<string>;
|
||||
coords: Array<{ lat: number; lng: number }>;
|
||||
/** Provider ids (google_place_id, google_ftid, osm_id) of the places already in the trip. */
|
||||
externalIds: Set<string>;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -206,6 +206,31 @@ export type CollectionPlaceUpdateRequest = z.infer<typeof collectionPlaceUpdateR
|
||||
export const collectionSetStatusRequestSchema = z.object({ status: collectionStatusSchema });
|
||||
export type CollectionSetStatusRequest = z.infer<typeof collectionSetStatusRequestSchema>;
|
||||
|
||||
/** 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<typeof collectionSetStatusManyRequestSchema>;
|
||||
|
||||
/** 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<typeof collectionSetStatusFromTripRequestSchema>;
|
||||
|
||||
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<typeof collectionSetStatusManyResponseSchema>;
|
||||
|
||||
/** 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<typeof collectionSaveResultSchema>;
|
||||
/** 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<typeof collectionMembershipSchema>;
|
||||
|
||||
|
||||
@@ -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} إلى رحلة',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} σε ταξίδι',
|
||||
|
||||
@@ -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': 'Δεν υπάρχουν καταχωρήσεις ακόμα',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}件を旅行にコピー',
|
||||
|
||||
@@ -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': 'エントリーはまだありません',
|
||||
|
||||
@@ -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}개를 여행에 복사',
|
||||
|
||||
@@ -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': '아직 항목이 없습니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} в поездку',
|
||||
|
||||
@@ -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': 'Пока нет записей',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} у подорож',
|
||||
|
||||
@@ -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': 'Поки що немає записів',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} 個複製到行程',
|
||||
|
||||
@@ -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': '還沒有條目',
|
||||
|
||||
@@ -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} 个复制到行程',
|
||||
|
||||
@@ -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': '还没有条目',
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user