mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e725d4f0a9 | |||
| 36f1b5b7d9 | |||
| acce063dbf | |||
| d229613e0e | |||
| 5622113348 | |||
| cd2ecf897b | |||
| f5af9d1259 | |||
| cecfdf2dcc | |||
| 23b28a1f3f | |||
| f7f6b83d33 | |||
| 097c1da0af | |||
| a74a2cf9b5 | |||
| dc1ff06573 | |||
| fc631d36b6 | |||
| 6463060f67 | |||
| 593f839592 | |||
| a9c5cf6d9a | |||
| f901982903 | |||
| 8f64431ff1 | |||
| c378722d63 | |||
| bfec874d04 | |||
| a36019b7e2 |
@@ -269,7 +269,7 @@
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
|
||||
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
|
||||
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { useConnectForm } from './useConnectForm'
|
||||
import { Badge } from './DocSyncBits'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import type { DocSyncProvider, useDocSync } from './useDocSync'
|
||||
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
* Connecting a store, as a dialog rather than a panel that unfolds in place.
|
||||
@@ -32,7 +33,7 @@ export default function DocSyncConnectModal({
|
||||
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
|
||||
|
||||
const form = useConnectForm(provider, existing, sync)
|
||||
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
|
||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
||||
|
||||
const test = async () => setVerdict(await form.probe())
|
||||
const labelId = useId()
|
||||
@@ -134,7 +135,7 @@ function Verdict({
|
||||
busy,
|
||||
failure,
|
||||
}: {
|
||||
verdict: { connected: boolean; account?: string; error?: string } | null
|
||||
verdict: DocSyncVerdict | null
|
||||
busy: boolean
|
||||
/** A call that failed outside the connection test (saving, most of all). */
|
||||
failure: string | null
|
||||
@@ -172,10 +173,16 @@ function Verdict({
|
||||
)
|
||||
}
|
||||
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
|
||||
const message = t(`docsync.error.${verdict.error || 'unknown'}`)
|
||||
// The footer shares its row with the buttons, and a refusal cut to "The
|
||||
// credentials were re..." with no way to read the rest left nothing to go on
|
||||
// (#2666). Two lines, and the whole of it with the store's reason on hover.
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
||||
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
|
||||
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
|
||||
</span>
|
||||
<Tooltip label={verdict.detail ? `${message} ${verdict.detail}` : message} placement="top">
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
||||
{blocked ? <ShieldAlert size={14} className="flex-none" /> : <AlertTriangle size={14} className="flex-none" />}
|
||||
<span className="line-clamp-2 [overflow-wrap:anywhere]">{message}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync'
|
||||
import type { DocSyncConnection, DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
* The credential form's rules, for both shells.
|
||||
@@ -39,7 +39,7 @@ export interface ConnectForm {
|
||||
setInsecureTls: (on: boolean) => void
|
||||
/** Every required field is either filled in or already stored. */
|
||||
complete: boolean
|
||||
probe: () => Promise<{ connected: boolean; account?: string; error?: string }>
|
||||
probe: () => Promise<DocSyncVerdict>
|
||||
save: () => Promise<boolean>
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@ import { useServerPing } from '../../../sync/useServerPing'
|
||||
* reason for the same problem.
|
||||
*/
|
||||
|
||||
/** What a connection test answers; `detail` is the store's own reason, for the person fixing it. */
|
||||
export interface DocSyncVerdict {
|
||||
connected: boolean
|
||||
account?: string
|
||||
error?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface DocSyncField {
|
||||
field_key: string
|
||||
label: string
|
||||
@@ -264,11 +272,9 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
||||
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
|
||||
setBusy('test')
|
||||
try {
|
||||
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
|
||||
connected: boolean; account?: string; error?: string; detail?: string
|
||||
}
|
||||
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as DocSyncVerdict
|
||||
} catch (e: unknown) {
|
||||
return { connected: false, error: readError(e) }
|
||||
return { connected: false, error: readError(e) } satisfies DocSyncVerdict
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
|
||||
import { calculateRouteWithRetries, ROUTE_RETRY_DELAYS_MS } from './routeRetry';
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./RouteCalculator')>();
|
||||
return { ...actual, calculateRouteWithLegs: vi.fn() };
|
||||
});
|
||||
|
||||
const WAYPOINTS = [
|
||||
{ lat: 47.5, lng: 19.04 },
|
||||
{ lat: 47.49, lng: 19.05 },
|
||||
];
|
||||
const ROUTED = {
|
||||
coordinates: [
|
||||
[47.5, 19.04],
|
||||
[47.49, 19.05],
|
||||
],
|
||||
legs: [],
|
||||
} as unknown as Awaited<ReturnType<typeof calculateRouteWithLegs>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(calculateRouteWithLegs).mockReset();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('calculateRouteWithRetries', () => {
|
||||
it('waits out a rate limit and answers with the road (Discord, 16.10.2026)', async () => {
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
||||
.mockResolvedValueOnce(ROUTED);
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, { profile: 'driving' });
|
||||
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
|
||||
|
||||
await expect(answer).resolves.toBe(ROUTED);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('waits as long as the host asks when that is longer', async () => {
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(503, 3000))
|
||||
.mockResolvedValueOnce(ROUTED);
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, {});
|
||||
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(3000 - ROUTE_RETRY_DELAYS_MS[0]);
|
||||
|
||||
await expect(answer).resolves.toBe(ROUTED);
|
||||
});
|
||||
|
||||
it('gives up after the last delay and throws the refusal', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null));
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, {});
|
||||
const settled = expect(answer).rejects.toBeInstanceOf(RoutingRefusedError);
|
||||
for (const ms of ROUTE_RETRY_DELAYS_MS) await vi.advanceTimersByTimeAsync(ms);
|
||||
|
||||
await settled;
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(ROUTE_RETRY_DELAYS_MS.length + 1);
|
||||
});
|
||||
|
||||
it('throws a refusal the host meant at once', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(400, null));
|
||||
|
||||
await expect(calculateRouteWithRetries(WAYPOINTS, {})).rejects.toBeInstanceOf(RoutingRefusedError);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops waiting when the day changes', async () => {
|
||||
const controller = new AbortController();
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
||||
.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, { signal: controller.signal });
|
||||
const settled = expect(answer).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
controller.abort();
|
||||
|
||||
await settled;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
|
||||
|
||||
// The profile a caller hands in, so a hook routing through here needs nothing else from Map/.
|
||||
export type { RouteProfileKey } from './RouteCalculator';
|
||||
|
||||
/** How often a rate-limited route is asked for again, and how long after. */
|
||||
export const ROUTE_RETRY_DELAYS_MS = [1500, 4000];
|
||||
|
||||
/** A pause that ends early, without throwing, when the signal aborts. */
|
||||
export const sleepUnlessAborted = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (signal?.aborted || ms <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(done, ms);
|
||||
function done(): void {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', done);
|
||||
resolve();
|
||||
}
|
||||
signal?.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* `calculateRouteWithLegs`, asked again while the host says it is busy.
|
||||
*
|
||||
* The public OSRM hosts allow about one request a second and answer 429 above that. A
|
||||
* day with a change of mode, or a quick switch between days, asks more often than that,
|
||||
* and the day route used to give up on the first refusal and keep its straight lines
|
||||
* until the next edit (reported on Discord, 16.10.2026). A rate limit is waited out, for
|
||||
* as long as the host asks when it says; a refusal the host meant is thrown at once,
|
||||
* because the same request earns the same answer. An abort ends the wait, and the next
|
||||
* attempt then throws the AbortError the caller already handles.
|
||||
*/
|
||||
export async function calculateRouteWithRetries(
|
||||
...[waypoints, opts]: Parameters<typeof calculateRouteWithLegs>
|
||||
): ReturnType<typeof calculateRouteWithLegs> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await calculateRouteWithLegs(waypoints, opts);
|
||||
} catch (err) {
|
||||
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null;
|
||||
if (!rateLimit || attempt >= ROUTE_RETRY_DELAYS_MS.length || opts?.signal?.aborted) throw err;
|
||||
await sleepUnlessAborted(Math.max(ROUTE_RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), opts?.signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
|
||||
import type { RouteProfileKey } from './RouteCalculator'
|
||||
import { calculateRouteWithRetries, sleepUnlessAborted as sleep } from './routeRetry'
|
||||
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
@@ -185,20 +186,6 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
|
||||
const REQUEST_SPACING_MS = 1100
|
||||
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
|
||||
const CACHE_HIT_MS = 60
|
||||
/** How often a rate-limited leg is tried again, and how long after. */
|
||||
const RETRY_DELAYS_MS = [1500, 4000]
|
||||
|
||||
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
new Promise(resolve => {
|
||||
if (signal?.aborted || ms <= 0) { resolve(); return }
|
||||
const timer = setTimeout(done, ms)
|
||||
function done(): void {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
signal?.addEventListener('abort', done, { once: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* Ask the router for every leg of every day, one at a time.
|
||||
@@ -233,23 +220,16 @@ export async function routeTripLegs(
|
||||
runs.forEach((chunks, r) => {
|
||||
chunks.forEach((chunk, c) => {
|
||||
tasks.push(async () => {
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithLegs(
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||
onAnswer?.(routed)
|
||||
return
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return
|
||||
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null
|
||||
if (!rateLimit || attempt === RETRY_DELAYS_MS.length) return
|
||||
// When the host says how long to wait, waiting less is just a second refusal.
|
||||
await sleep(Math.max(RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), signal)
|
||||
}
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithRetries(
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||
onAnswer?.(routed)
|
||||
} catch {
|
||||
// Refused for good, or aborted: the leg keeps its straight line.
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -271,7 +271,9 @@ describe('PackingListPanel', () => {
|
||||
];
|
||||
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
||||
expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(container.querySelector('[draggable="true"]')).toBeNull();
|
||||
// Only the category grip stays: the order of the categories is not the A to Z of their items.
|
||||
const draggable = [...container.querySelectorAll('[draggable="true"]')];
|
||||
expect(draggable.map((el) => el.getAttribute('aria-label'))).toEqual(['Drag to reorder the category']);
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
Trash2, Plus, ChevronDown, ChevronRight,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus, GripVertical,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { katColor } from './packingListPanel.helpers'
|
||||
@@ -38,6 +38,16 @@ interface KategorieGruppeProps {
|
||||
// Off while the list is shown A-Z: a drop there would move an item in an order
|
||||
// the reader cannot see.
|
||||
reorderable?: boolean
|
||||
// Moving the whole category among the others, wired by the list, which sees every
|
||||
// card. `onDrop` gets the category that was dragged onto this one.
|
||||
categoryDrag?: {
|
||||
isDragging: boolean
|
||||
isOver: boolean
|
||||
onStart: () => void
|
||||
onOver: () => void
|
||||
onEnd: () => void
|
||||
onDrop: (dragged: string) => void
|
||||
}
|
||||
// Three-tier sharing (#858) — threaded down to each item's share control.
|
||||
currentUserId?: number
|
||||
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
|
||||
@@ -46,7 +56,7 @@ interface KategorieGruppeProps {
|
||||
onLeave?: (id: number, userId: number) => void
|
||||
}
|
||||
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, categoryDrag, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [dragId, setDragId] = useState<number | null>(null)
|
||||
const [overId, setOverId] = useState<number | null>(null)
|
||||
@@ -120,8 +130,9 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
return (
|
||||
// Shaped after the phone's category card: a tinted head band carrying the
|
||||
// name as written, the count, and the fold arrow on the far right.
|
||||
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
|
||||
<CategoryDropFrame drag={categoryDrag}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
|
||||
<CategoryGrip drag={categoryDrag} kategorie={kategorie} label={t('packing.dragCategory')} />
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
||||
|
||||
{editingName && canEdit ? (
|
||||
@@ -344,6 +355,65 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CategoryDropFrame>
|
||||
)
|
||||
}
|
||||
|
||||
type CategoryDrag = NonNullable<KategorieGruppeProps['categoryDrag']>
|
||||
|
||||
/** The data type a dragged category carries, its name as the value. */
|
||||
const CATEGORY_DRAG_TYPE = 'application/x-trek-packing-category'
|
||||
|
||||
/**
|
||||
* The card, and while a category is dragged its drop target. Captured, because
|
||||
* the item rows stop their own drops from bubbling.
|
||||
*/
|
||||
function CategoryDropFrame({ drag, children }: { drag?: CategoryDrag; children: React.ReactNode }) {
|
||||
// Told apart by the drag's own data type, which is there from the first event on:
|
||||
// a flag set in state on dragstart can still be unrendered when a quick drag
|
||||
// arrives, and a dragged item must pass through to its rows untouched.
|
||||
const isCategoryDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(CATEGORY_DRAG_TYPE)
|
||||
return (
|
||||
<div
|
||||
onDragOverCapture={drag ? (e => {
|
||||
if (!isCategoryDrag(e)) return
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag.onOver()
|
||||
}) : undefined}
|
||||
onDropCapture={drag ? (e => {
|
||||
if (!isCategoryDrag(e)) return
|
||||
e.preventDefault(); e.stopPropagation(); drag.onDrop(e.dataTransfer.getData(CATEGORY_DRAG_TYPE))
|
||||
}) : undefined}
|
||||
style={{
|
||||
marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible',
|
||||
opacity: drag?.isDragging ? 0.4 : 1,
|
||||
boxShadow: drag?.isOver ? '0 0 0 2px var(--accent)' : 'none',
|
||||
transition: 'opacity 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The handle a category is dragged by, in the head band before its colour dot. */
|
||||
function CategoryGrip({ drag, kategorie, label }: { drag?: CategoryDrag; kategorie: string; label: string }) {
|
||||
if (!drag) return null
|
||||
return (
|
||||
<span
|
||||
draggable
|
||||
role="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData(CATEGORY_DRAG_TYPE, kategorie)
|
||||
e.dataTransfer.setData('text/plain', kategorie)
|
||||
drag.onStart()
|
||||
}}
|
||||
onDragEnd={drag.onEnd}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginInlineStart: -6 }}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import { KategorieGruppe } from './PackingListPanelCategoryGroup'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -7,8 +8,13 @@ export function PackingList(S: PackingState) {
|
||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
||||
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
|
||||
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort,
|
||||
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort, moveCategory,
|
||||
} = S
|
||||
// Which category is being dragged and which card it is over. Held here, above
|
||||
// the cards, because the drag starts in one card and ends in another.
|
||||
const [draggedCategory, setDraggedCategory] = useState<string | null>(null)
|
||||
const [overCategory, setOverCategory] = useState<string | null>(null)
|
||||
const endCategoryDrag = () => { setDraggedCategory(null); setOverCategory(null) }
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
|
||||
{items.length === 0 ? (
|
||||
@@ -40,6 +46,14 @@ export function PackingList(S: PackingState) {
|
||||
allItems={items}
|
||||
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
|
||||
reorderable={sort === 'manual'}
|
||||
categoryDrag={canEdit ? {
|
||||
isDragging: draggedCategory === kat,
|
||||
isOver: overCategory === kat && draggedCategory !== null && draggedCategory !== kat,
|
||||
onStart: () => { setDraggedCategory(kat); setOverCategory(null) },
|
||||
onOver: () => setOverCategory(kat),
|
||||
onEnd: endCategoryDrag,
|
||||
onDrop: (dragged) => { moveCategory(dragged, kat); endCategoryDrag() },
|
||||
} : undefined}
|
||||
currentUserId={currentUserId}
|
||||
onSetSharing={handleSetSharing}
|
||||
onClone={handleCloneItem}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
groupPackingItems,
|
||||
isLastCustomItemInCategory,
|
||||
isPackingPlaceholder,
|
||||
itemOrderWithCategoryMoved,
|
||||
packingCategoryOrder,
|
||||
packingItemWeight,
|
||||
packingProgress,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
} from './packingListModel';
|
||||
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
|
||||
|
||||
// FE-PACK-MODEL-001 to FE-PACK-MODEL-008
|
||||
// FE-PACK-MODEL-001 to FE-PACK-MODEL-009
|
||||
|
||||
const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 });
|
||||
const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 });
|
||||
@@ -76,4 +77,28 @@ describe('packingListModel', () => {
|
||||
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
|
||||
expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false);
|
||||
});
|
||||
it('FE-PACK-MODEL-009: moving a category moves its items as a block, the other view stays as it was', () => {
|
||||
const day1 = buildPackingItem({ id: 11, name: 'Shirt', category: 'Day 1' });
|
||||
const day2a = buildPackingItem({ id: 12, name: 'Shorts', category: 'Day 2' });
|
||||
const own = buildPackingItem({ id: 13, name: 'Pills', category: 'Day 2', is_private: 1 });
|
||||
const day3 = buildPackingItem({ id: 14, name: 'Jacket', category: 'Day 3' });
|
||||
const day2b = buildPackingItem({ id: 15, name: 'Hat', category: 'Day 2' });
|
||||
const items = [day1, day2a, own, day3, day2b];
|
||||
const common = packingViewItems(items, 'common');
|
||||
|
||||
const order = itemOrderWithCategoryMoved(items, common, 'Day 3', 'Day 1', 'Other');
|
||||
expect(order).toEqual([14, 11, 12, 15, 13]);
|
||||
const moved = (order ?? []).flatMap((id) => items.filter((i) => i.id === id));
|
||||
expect(packingCategoryOrder(packingViewItems(moved, 'common'), 'Other')).toEqual(['Day 3', 'Day 1', 'Day 2']);
|
||||
expect(packingCategoryOrder(packingViewItems(moved, 'personal'), 'Other')).toEqual(['Day 2']);
|
||||
|
||||
expect(itemOrderWithCategoryMoved(items, common, 'Day 1', 'Day 3', 'Other')).toEqual([12, 15, 14, 11, 13]);
|
||||
});
|
||||
|
||||
it('FE-PACK-MODEL-009b: nothing moves for the same category or one the view does not show', () => {
|
||||
const items = [shared, stove, loose];
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Camp', 'Camp', 'Other')).toBeNull();
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Clothes', 'Camp', 'Other')).toBeNull();
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Other', 'Camp', 'Other')).toEqual([4, 1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,34 @@ export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory:
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole list's item order with category `from` moved to where `to` stands.
|
||||
*
|
||||
* A category has no position of its own: it shows where its first item is. So a
|
||||
* category is moved by moving its items, as one block, through the item reorder
|
||||
* the list already has; nothing new is stored, and the order reaches every device
|
||||
* the way a dragged item does. The view's items come first, grouped in the new
|
||||
* order with each group in its own order; the other view's items follow in theirs,
|
||||
* which leaves that view's categories as they were. Null when nothing would move.
|
||||
*/
|
||||
export function itemOrderWithCategoryMoved(
|
||||
items: PackingItem[],
|
||||
viewItems: PackingItem[],
|
||||
from: string,
|
||||
to: string,
|
||||
defaultCategory: string
|
||||
): number[] | null {
|
||||
const order = packingCategoryOrder(viewItems, defaultCategory);
|
||||
const fromIndex = order.indexOf(from);
|
||||
const toIndex = order.indexOf(to);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
|
||||
order.splice(fromIndex, 1);
|
||||
order.splice(toIndex, 0, from);
|
||||
const inView = new Set(viewItems.map((i) => i.id));
|
||||
const grouped = order.flatMap((cat) => viewItems.filter((i) => (i.category || defaultCategory) === cat));
|
||||
return [...grouped, ...items.filter((i) => !inView.has(i.id))].map((i) => i.id);
|
||||
}
|
||||
|
||||
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
|
||||
if (status === 'open') return items.filter((i) => !i.checked);
|
||||
if (status === 'done') return items.filter((i) => !!i.checked);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { tripsApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { PackingItem } from '../../types'
|
||||
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
|
||||
import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||
import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||
import { usePackingBags } from './usePackingBags'
|
||||
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
|
||||
import { usePackingImport } from './usePackingImport'
|
||||
@@ -106,6 +106,13 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
|
||||
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
|
||||
|
||||
// A category dropped onto another takes its place; the slice reorders
|
||||
// optimistically and rolls back with a toast of its own.
|
||||
const moveCategory = (from: string, to: string) => {
|
||||
const orderedIds = itemOrderWithCategoryMoved(items, viewItems, from, to, t('packing.defaultCategory'))
|
||||
if (orderedIds) void reorderPackingItems(tripId, orderedIds)
|
||||
}
|
||||
|
||||
const {
|
||||
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
|
||||
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
|
||||
@@ -213,7 +220,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
|
||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
|
||||
filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, moveCategory,
|
||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||
|
||||
@@ -308,9 +308,10 @@ export default function AccountTab(): React.ReactElement {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
value={mfaDisableCode}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
aria-label={t('settings.mfa.codePlaceholder')}
|
||||
className={`${INPUT} font-geist tabular-nums`}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator'
|
||||
import { calculateRouteWithRetries, type RouteProfileKey } from '../components/Map/routeRetry'
|
||||
import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
|
||||
import { dayViaLookup, foldViaLegs, threadVias, viaKeyOf, viasOnDayLeg } from '../utils/dayRouteVias'
|
||||
import { resolveLegMode } from '../components/Planner/legMode'
|
||||
@@ -131,7 +131,8 @@ export function useRouteCalculation(tripStore: Pick<TripStoreState, 'assignments
|
||||
const straight = (): [number, number][] => chunk.map(p => [p.lat, p.lng] as [number, number])
|
||||
const { waypoints, stopAt } = threadVias(chunk, (from, to) => viasOnDayLeg(viaLookup, dayId, mode, from, to))
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(waypoints, { signal: controller.signal, profile: mode, tripId, dayId })
|
||||
// A busy router is waited out rather than left as straight lines until the next edit.
|
||||
const r = await calculateRouteWithRetries(waypoints, { signal: controller.signal, profile: mode, tripId, dayId })
|
||||
pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight(), mode === 'walking')
|
||||
// Leg k runs from chunk[k] to chunk[k + 1]; a hotel bookend says so on the
|
||||
// segment, which is how the phone timeline finds it (#2501).
|
||||
|
||||
@@ -244,9 +244,10 @@ export default function MSettingsAccount() {
|
||||
/>
|
||||
<MSetInput
|
||||
className="mt-2"
|
||||
inputMode="numeric"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
value={mfaDisableCode}
|
||||
onChange={(e) => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
onChange={(e) => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
/>
|
||||
<MSetButton
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constan
|
||||
import { formatTime, formatPriceText } from '../../../../utils/formatters'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
|
||||
import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import { cardWhen, parseTransportMeta } from './transportsModel'
|
||||
@@ -173,11 +173,7 @@ function BookingCard({ res, planner, canEdit, compact }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{res.notes && (
|
||||
<div className="mt-2 rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-2">
|
||||
<p className="whitespace-pre-wrap font-geist text-[0.6875rem] leading-[1.5] text-m-muted">{res.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
<CardNotes notes={res.notes} />
|
||||
|
||||
<CardFiles files={files} card={card} t={t} />
|
||||
</button>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useTranslation } from '../../../../i18n'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons'
|
||||
import TrekIcon from '../../../../components/shared/TrekIcon'
|
||||
import {
|
||||
bindingNotice, needsReauth, storeName, useDocSync, type DocSyncLink, type DocSyncProvider,
|
||||
bindingNotice, needsReauth, storeName, useDocSync, type DocSyncLink, type DocSyncProvider, type DocSyncVerdict,
|
||||
} from '../../../../components/Files/docsync/useDocSync'
|
||||
import { useConnectForm } from '../../../../components/Files/docsync/useConnectForm'
|
||||
import { conflictPolicyKey, nextConflictPolicy } from '../../../../components/Files/docsync/DocSyncBits'
|
||||
@@ -450,7 +450,7 @@ function ConnectView({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const form = useConnectForm(provider, sync.connectionFor(provider.id), sync)
|
||||
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
|
||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
@@ -503,6 +503,10 @@ function ConnectView({
|
||||
? t('docsync.connect.okAs', { account: verdict.account })
|
||||
: t('docsync.connected')
|
||||
: t(`docsync.error.${verdict.error ?? 'unknown'}`)}
|
||||
{/* The store's own reason, so a refusal can be told apart from a typo (#2666). */}
|
||||
{!verdict.connected && verdict.detail && (
|
||||
<span className="mt-0.5 block text-m-muted [overflow-wrap:anywhere]">{verdict.detail}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-009
|
||||
// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-010
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Hotel, Plane } from 'lucide-react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard';
|
||||
import type { Reservation } from '../../../../types';
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts';
|
||||
import {
|
||||
CardActions,
|
||||
CardDeleteSheet,
|
||||
CardFiles,
|
||||
CardNotes,
|
||||
CardTitleButton,
|
||||
CardWhenAndCode,
|
||||
} from './MReservationCardParts';
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}:${Object.values(params).join(',')}` : key;
|
||||
@@ -149,4 +156,15 @@ describe('MReservationCardParts', () => {
|
||||
expect(title.contains(date)).toBe(false);
|
||||
expect(container.querySelector('button > div')).toHaveClass('flex-col');
|
||||
});
|
||||
|
||||
it('FE-COMP-MRESCARD-010: shows the notes of a booking, and nothing for empty ones (Discord, 15.10.2026)', () => {
|
||||
const notes = 'Gate closes 40 min early\nBag drop at counter C';
|
||||
const { container, rerender } = render(<CardNotes notes={notes} />);
|
||||
expect(container.querySelector('p')?.textContent).toBe(notes);
|
||||
|
||||
rerender(<CardNotes notes=" " />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
rerender(<CardNotes notes={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,6 +128,22 @@ export function CardWhenAndCode({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's notes, inside the card body button. A transport card on the phone
|
||||
* never showed them, so notes typed into a flight were nowhere to be read
|
||||
* (reported on Discord, 15.10.2026); both cards now take them from here.
|
||||
*/
|
||||
export function CardNotes({ notes }: { notes: string | null | undefined }) {
|
||||
if (!notes?.trim()) return null;
|
||||
return (
|
||||
<div className="mt-2 rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-2">
|
||||
<p className="whitespace-pre-wrap font-geist text-caption leading-[1.5] text-m-muted [overflow-wrap:anywhere]">
|
||||
{notes}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Attached files, listed inside the card body button. */
|
||||
export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFor>; card: CardState; t: T }) {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constan
|
||||
import { formatPriceText } from '../../../../utils/formatters'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
|
||||
import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import { cardWhen, parseTransportMeta } from './transportsModel'
|
||||
@@ -189,6 +189,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
||||
|
||||
<TravelerAvatars travelers={res.travelers || []} label={t('reservations.travelers.label')} />
|
||||
|
||||
<CardNotes notes={res.notes} />
|
||||
|
||||
<CardFiles files={files} card={card} t={t} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useAddonStore } from '../store/addonStore';
|
||||
import { usePermissionsStore } from '../store/permissionsStore';
|
||||
import JourneyPage from './JourneyPage';
|
||||
|
||||
const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn() }));
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
@@ -24,6 +26,10 @@ vi.mock('../components/Layout/Navbar', () => ({
|
||||
default: () => <nav data-testid="navbar" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/shared/Toast', () => ({
|
||||
useToast: () => ({ error: mockToastError }),
|
||||
}));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let _seq = 500;
|
||||
@@ -78,6 +84,7 @@ function setupDefaultHandlers(journeys: ReturnType<typeof buildJourneyListItem>[
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
mockNavigate.mockReset();
|
||||
resetAllStores();
|
||||
seedDefaults();
|
||||
@@ -193,6 +200,75 @@ describe('JourneyPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-007c: keeps a dismissed suggestion hidden after remount', async () => {
|
||||
const user = userEvent.setup();
|
||||
const suggestions = [
|
||||
{ id: 991, title: 'Kyoto Weekend', start_date: '2026-03-01', end_date: '2026-03-02', place_count: 2 },
|
||||
{ id: 993, title: 'Lisbon Loop', start_date: '2026-04-01', end_date: '2026-04-02', place_count: 3 },
|
||||
];
|
||||
server.use(
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
|
||||
http.get('/api/journeys/suggestions', () => HttpResponse.json({ trips: suggestions })),
|
||||
);
|
||||
|
||||
const firstRender = render(<JourneyPage />);
|
||||
const dismissButton = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
await user.click(dismissButton);
|
||||
|
||||
expect(await screen.findByText(/Lisbon Loop/)).toBeInTheDocument();
|
||||
const dismissedRecord = JSON.parse(window.localStorage.getItem(`trek:journey:dismissed-suggestions:v1:${useAuthStore.getState().user?.id}`) || '[]');
|
||||
expect(dismissedRecord).toContain(991);
|
||||
firstRender.unmount();
|
||||
|
||||
render(<JourneyPage />);
|
||||
expect(await screen.findByText(/Lisbon Loop/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Kyoto Weekend/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-007d: keeps the suggestion visible and reports a storage failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
const suggestion = { id: 992, title: 'Coast Trip', start_date: '2026-03-01', end_date: '2026-03-02', place_count: 2 };
|
||||
mockToastError.mockClear();
|
||||
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('Storage is full', 'QuotaExceededError');
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
|
||||
http.get('/api/journeys/suggestions', () => HttpResponse.json({ trips: [suggestion] })),
|
||||
);
|
||||
|
||||
render(<JourneyPage />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Dismiss' }));
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('Could not dismiss this suggestion');
|
||||
expect(screen.getByText(/Trip just ended/i)).toBeInTheDocument();
|
||||
setItem.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-007e: deletes dismissed IDs absent from the suggestions response', async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const absentTripId = 994;
|
||||
const stillDismissedTripId = 995;
|
||||
const returnedTripId = 996;
|
||||
const key = `trek:journey:dismissed-suggestions:v1:${userId}`;
|
||||
window.localStorage.setItem(key, JSON.stringify([absentTripId, stillDismissedTripId]));
|
||||
server.use(
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
|
||||
http.get('/api/journeys/suggestions', () => HttpResponse.json({
|
||||
trips: [
|
||||
{ id: returnedTripId, title: 'Returned Trip', start_date: '2026-04-01', end_date: '2026-04-02', place_count: 2 },
|
||||
{ id: stillDismissedTripId, title: 'Still Dismissed', start_date: '2026-05-01', end_date: '2026-05-02', place_count: 2 },
|
||||
],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<JourneyPage />);
|
||||
|
||||
expect(await screen.findByText(/Returned Trip/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Still Dismissed/)).not.toBeInTheDocument();
|
||||
expect(JSON.parse(window.localStorage.getItem(key) || '[]')).toEqual([stillDismissedTripId]);
|
||||
});
|
||||
|
||||
// FE-PAGE-JOURNEY-007b — XSS regression: the suggestion banner interpolates
|
||||
// a user-controlled trip title into an HTML template that is later passed to
|
||||
// dangerouslySetInnerHTML. The sanitiser in @trek/shared must drop any script
|
||||
@@ -266,7 +342,7 @@ describe('JourneyPage', () => {
|
||||
});
|
||||
|
||||
// Click dismiss
|
||||
await user.click(screen.getByText(/Dismiss/i));
|
||||
await user.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Trip just ended/i)).not.toBeInTheDocument();
|
||||
|
||||
@@ -45,7 +45,7 @@ function JourneyPageDesktop() {
|
||||
showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle,
|
||||
availableTrips, selectedTripIds, setSelectedTripIds,
|
||||
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
|
||||
activeSuggestion, setDismissedSuggestions,
|
||||
activeSuggestion, dismissSuggestion,
|
||||
activeJourney, activeJourneyIsLive, filteredJourneys,
|
||||
openCreateModal, handleCreate, totalPlaces,
|
||||
} = useJourney()
|
||||
@@ -112,7 +112,7 @@ function JourneyPageDesktop() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => setDismissedSuggestions(prev => new Set([...prev, activeSuggestion.id]))}
|
||||
onClick={() => dismissSuggestion(activeSuggestion.id)}
|
||||
className="px-3 py-1.5 rounded-lg bg-white/10 border border-white/20 text-[12px] font-medium text-white hover:bg-white/20"
|
||||
>
|
||||
{t('journey.frontpage.dismiss')}
|
||||
|
||||
@@ -38,7 +38,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
|
||||
availableTrips: [], selectedTripIds: new Set<number>(), setSelectedTripIds: vi.fn(),
|
||||
searchOpen: false, setSearchOpen: vi.fn(), searchQuery: '', setSearchQuery: vi.fn(),
|
||||
searchInputRef: { current: null },
|
||||
activeSuggestion: undefined, setDismissedSuggestions: vi.fn(),
|
||||
activeSuggestion: undefined, dismissSuggestion: vi.fn(),
|
||||
activeJourney: null, activeJourneyIsLive: false,
|
||||
filteredJourneys: journeys,
|
||||
openCreateModal: vi.fn(), handleCreate: vi.fn(), totalPlaces: 0,
|
||||
@@ -116,9 +116,7 @@ describe('JourneyPage wiring', () => {
|
||||
expect(screen.getByText('Trip just ended')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||
const updater = (hook.setDismissedSuggestions as ReturnType<typeof vi.fn>).mock.calls[0][0] as
|
||||
(prev: Set<number>) => Set<number>;
|
||||
expect([...updater(new Set<number>())]).toEqual([42]);
|
||||
expect(hook.dismissSuggestion).toHaveBeenCalledWith(42);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Create Journey' })[1]);
|
||||
expect(hook.openCreateModal).toHaveBeenCalledWith(42);
|
||||
|
||||
@@ -1586,8 +1586,9 @@ describe('SharedTripPage', () => {
|
||||
id: 301, day_id: 21, order_index: 0, notes: 'go early, before the coaches',
|
||||
place: {
|
||||
id: 501, name: 'Louvre', description: 'The big one', address: 'Rue de Rivoli, Paris',
|
||||
lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: null,
|
||||
duration_minutes: 150, notes: 'Skip the pyramid queue', website: 'https://louvre.fr',
|
||||
// The stay comes from the window; the place default of an hour is not a plan (#2676).
|
||||
lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: '11:30',
|
||||
duration_minutes: 60, notes: 'Skip the pyramid queue', website: 'https://louvre.fr',
|
||||
phone: '+33 1 40 20 50 50', image_url: null, transport_mode: 'walking', category: null, tags: [],
|
||||
},
|
||||
},
|
||||
@@ -1639,6 +1640,23 @@ describe('SharedTripPage', () => {
|
||||
expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
|
||||
expect(screen.queryByText(/min$/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no stay for a stop with only a start, whatever the place default says (#2676)', async () => {
|
||||
const startOnly = {
|
||||
21: [{
|
||||
id: 303, day_id: 21, order_index: 0, notes: null,
|
||||
place: {
|
||||
id: 503, name: 'Café', description: null, address: 'Somewhere 1', lat: null, lng: null,
|
||||
duration_minutes: 60, notes: null, website: null, phone: null,
|
||||
category_id: null, price: null, place_time: '15:00', end_time: null, image_url: null,
|
||||
transport_mode: 'walking', category: null, tags: [],
|
||||
},
|
||||
}],
|
||||
};
|
||||
await open('start-only-token', payload({ days, assignments: startOnly }));
|
||||
await waitFor(() => expect(screen.getByText('Somewhere 1')).toBeInTheDocument());
|
||||
expect(screen.queryByText('1 h')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-SHARED-039: a booking shows its note and its link, and nothing that is not http(s) (#2320)', () => {
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { computeJourneyLifecycle } from '../../utils/journeyLifecycle'
|
||||
|
||||
function dismissedSuggestionsKey(userId: number | undefined): string {
|
||||
return `trek:journey:dismissed-suggestions:v1:${userId ?? 'anonymous'}`
|
||||
}
|
||||
|
||||
/** The trips this user put away, as stored here; anything unreadable reads as none. */
|
||||
function readDismissedSuggestions(userId: number | undefined): Set<number> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(window.localStorage.getItem(dismissedSuggestionsKey(userId)) || '[]')
|
||||
return new Set(Array.isArray(value) ? value.filter((id): id is number => Number.isInteger(id)) : [])
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function saveDismissedSuggestions(userId: number | undefined, dismissed: Set<number>): void {
|
||||
const key = dismissedSuggestionsKey(userId)
|
||||
if (dismissed.size === 0) {
|
||||
window.localStorage.removeItem(key)
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(key, JSON.stringify([...dismissed]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Journey list page logic — owns the journey store load, the create-journey
|
||||
* modal (title, available trips, selection), the search box, the trip
|
||||
@@ -18,6 +42,7 @@ export function useJourney() {
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { journeys, loading, loadJourneys, createJourney } = useJourneyStore()
|
||||
const userId = useAuthStore(s => s.user?.id)
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
@@ -30,15 +55,35 @@ export function useJourney() {
|
||||
|
||||
// suggestion
|
||||
const [suggestions, setSuggestions] = useState<any[]>([])
|
||||
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(new Set())
|
||||
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(() => readDismissedSuggestions(userId))
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
loadJourneys()
|
||||
journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
journeyApi.suggestions().then(d => {
|
||||
if (cancelled) return
|
||||
const trips = d.trips || []
|
||||
const suggestionIds = new Set<number>(trips.map((trip: { id: number }) => trip.id))
|
||||
const stored = readDismissedSuggestions(userId)
|
||||
const retained = new Set([...stored].filter(id => suggestionIds.has(id)))
|
||||
setSuggestions(trips)
|
||||
setDismissedSuggestions(retained)
|
||||
if (retained.size !== stored.size) {
|
||||
try {
|
||||
saveDismissedSuggestions(userId, retained)
|
||||
} catch {
|
||||
// Pruning is best effort; a failed write leaves the old IDs to retry later.
|
||||
}
|
||||
}
|
||||
}).catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [userId])
|
||||
|
||||
// The bottom-nav "+" opens the new-journey modal via ?create=1.
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === '1') {
|
||||
@@ -49,6 +94,17 @@ export function useJourney() {
|
||||
|
||||
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
|
||||
|
||||
const dismissSuggestion = (tripId: number) => {
|
||||
const next = new Set(dismissedSuggestions)
|
||||
next.add(tripId)
|
||||
try {
|
||||
saveDismissedSuggestions(userId, next)
|
||||
setDismissedSuggestions(next)
|
||||
} catch {
|
||||
toast.error(t('journey.frontpage.dismissError'))
|
||||
}
|
||||
}
|
||||
|
||||
// The frontpage always features one journey as the hero. A journey tied to a
|
||||
// currently-running trip wins; otherwise we fall back to the most recent one
|
||||
// (the list arrives sorted by updated_at DESC, so journeys[0] is the newest).
|
||||
@@ -118,7 +174,7 @@ export function useJourney() {
|
||||
showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle,
|
||||
availableTrips, selectedTripIds, setSelectedTripIds,
|
||||
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
|
||||
activeSuggestion, setDismissedSuggestions,
|
||||
activeSuggestion, dismissSuggestion,
|
||||
activeJourney, activeJourneyIsLive, filteredJourneys,
|
||||
openCreateModal, handleCreate, totalPlaces,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fs } from '../../components/shared/DialogShell'
|
||||
import { Tooltip } from '../../components/shared/Tooltip'
|
||||
import { WhiteBadge } from '../../components/Planner/planParts'
|
||||
import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps'
|
||||
import { formatDurationMinutes, isHttpUrl } from './sharedTripModel'
|
||||
import { formatDurationMinutes, isHttpUrl, plannedStayMinutes } from './sharedTripModel'
|
||||
|
||||
/**
|
||||
* The read-only detail of one stop on a shared plan (#2320).
|
||||
@@ -14,7 +14,8 @@ import { formatDurationMinutes, isHttpUrl } from './sharedTripModel'
|
||||
* usually also left a note on the place and another on the day it was
|
||||
* planned for, and a link would show none of that. This shows what the owner
|
||||
* chose to share, laid out the way the planner lays it out: address, then
|
||||
* description, then the two notes, then how long they meant to stay, then
|
||||
* description, then the two notes, then how long they meant to stay (from the
|
||||
* stop's start and end time), then
|
||||
* the ways to reach the place.
|
||||
*
|
||||
* Nothing here is editable and nothing here is a route into the app: every
|
||||
@@ -46,7 +47,7 @@ export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPl
|
||||
name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null,
|
||||
google_place_id: null, google_ftid: null,
|
||||
})
|
||||
const duration = formatDurationMinutes(place.duration_minutes)
|
||||
const duration = formatDurationMinutes(plannedStayMinutes(place.place_time, place.end_time))
|
||||
const dayNote = assignmentNotes?.trim() || null
|
||||
const placeNote = place.notes?.trim() || null
|
||||
const hasLinks = !!(website || phone || mapsUrl)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AxiosError, AxiosHeaders } from 'axios'
|
||||
import {
|
||||
coverSrc, dayHasEntries, formatDateRange, formatDurationMinutes, groupInOrder, isHttpUrl, isSharedTripPayload, legFacts, linkHost,
|
||||
coverSrc, dayHasEntries, formatDateRange, formatDurationMinutes, groupInOrder, isHttpUrl, isSharedTripPayload, legFacts, linkHost, plannedStayMinutes,
|
||||
sharedTripLoadError, spanLabelKey, stopNumbers, transportFacts, unplannedPlaces,
|
||||
} from './sharedTripModel'
|
||||
|
||||
@@ -25,6 +25,15 @@ describe('sharedTripModel (#2320)', () => {
|
||||
expect(formatDurationMinutes(89.6)).toBe('1 h 30 min')
|
||||
})
|
||||
|
||||
it('reads the planned stay off the time window, never off the place default (#2676)', () => {
|
||||
expect(plannedStayMinutes('10:00', '12:30')).toBe(150)
|
||||
expect(plannedStayMinutes('22:30', '01:00')).toBe(150)
|
||||
expect(plannedStayMinutes('10:00', null)).toBeNull()
|
||||
expect(plannedStayMinutes(null, '12:00')).toBeNull()
|
||||
expect(plannedStayMinutes('10:00', '10:00')).toBeNull()
|
||||
expect(plannedStayMinutes(undefined, undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('writes nothing for a missing, zero or nonsense figure', () => {
|
||||
expect(formatDurationMinutes(null)).toBeNull()
|
||||
expect(formatDurationMinutes(undefined)).toBeNull()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* page and its detail blocks can share them and a test can drive them
|
||||
* without rendering.
|
||||
*/
|
||||
import { parseTimeToMinutes } from '../../utils/dayMerge'
|
||||
|
||||
/**
|
||||
* Whether a string is a link the page may render as one.
|
||||
@@ -22,6 +23,22 @@ export function isHttpUrl(value: unknown): value is string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the owner planned to stay, read off the stop's time window.
|
||||
*
|
||||
* Not `places.duration_minutes`: every place carries one, 60 unless someone
|
||||
* set a road-trip stay, so a link showed "1 h" under every stop (#2676). The
|
||||
* window is what the owner actually planned, and it can differ per visit.
|
||||
* Without an end there is no stay to show. An end before the start runs past
|
||||
* midnight.
|
||||
*/
|
||||
export function plannedStayMinutes(start: string | null | undefined, end: string | null | undefined): number | null {
|
||||
const from = parseTimeToMinutes(start)
|
||||
const to = parseTimeToMinutes(end)
|
||||
if (from == null || to == null || from === to) return null
|
||||
return to > from ? to - from : to + 24 * 60 - from
|
||||
}
|
||||
|
||||
/**
|
||||
* "45 min", "2 h", "1 h 30 min" — the planned time at a place.
|
||||
*
|
||||
|
||||
@@ -52,6 +52,7 @@ services:
|
||||
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
||||
# - KITINERARY_EXTRACTOR_PATH= # Optional. Full path to kitinerary-extractor binary. Auto-detected from PATH and /usr/lib/*/libexec/kf6/ when unset.
|
||||
# - LLM_TIMEOUT_MS=900000 # Optional. Ceiling (ms) for one AI-parsing call before it is abandoned, applied to every provider and to the HTTP client underneath. Generous by default so heavier documents fit; lower it on a cloud provider to fail fast. Must be a whole number of milliseconds between 1 and 2147483647 — anything else stops the server at startup with the reason. (default: 900000)
|
||||
# - LLM_MAX_OUTPUT_TOKENS=8192 # Optional. Longest answer (tokens) one AI-parsing call may ask for. Raise it when a document with many bookings comes back cut off; keep prompt plus cap inside your model's context. Whole number from 256 to 1000000. (default: 4096 for OpenAI-compatible, 8192 for Anthropic)
|
||||
# - TREK_PLUGINS_IGNORE_TREK_RANGE=true # Optional. Lets plugins whose declared TREK version range predates this build, and plugins that declare none, be installed and activated anyway. Every gate warns instead of refusing; the plugin-API version gate and the Discover compatibility verdict stay hard. Off unless set. (default: off)
|
||||
# - UNSPLASH_ACCESS_KEY= # Optional. Unsplash Access Key for trip-cover / place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from (#1449). Free key at unsplash.com/developers. Can also be set per-admin in Admin > Settings; the env var overrides that.
|
||||
# - TREK_PLACES_ENABLED=false # Optional. Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads) asks the TREK Places API at https://places.liketrek.com by default, so the server makes outbound requests to that host. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing there. No switch in the admin panel on purpose: whether searches leave the instance is decided here. (default: true)
|
||||
|
||||
Generated
+8
-8
@@ -13,7 +13,7 @@
|
||||
"shared"
|
||||
],
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.4",
|
||||
"concurrently": "^10.0.6",
|
||||
"knip": "^6.38.0",
|
||||
"unrun": "^0.3.1"
|
||||
},
|
||||
@@ -11082,15 +11082,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz",
|
||||
"integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==",
|
||||
"version": "10.0.6",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.6.tgz",
|
||||
"integrity": "sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "5.6.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.9.0",
|
||||
"shell-quote": "1.12.0",
|
||||
"supports-color": "10.2.2",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "18.0.0"
|
||||
@@ -19748,9 +19748,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
||||
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
|
||||
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@
|
||||
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.4",
|
||||
"concurrently": "^10.0.6",
|
||||
"knip": "^6.38.0",
|
||||
"unrun": "^0.3.1"
|
||||
},
|
||||
|
||||
Generated
+3
-3
@@ -1906,9 +1906,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
|
||||
@@ -84,6 +84,7 @@ DEMO_MODE=false # Demo mode: resets data hourly
|
||||
# KITINERARY_EXTRACTOR_PATH= # Full path to the kitinerary-extractor binary. Unset, TREK searches /usr/lib/*/libexec/kf6/ and then PATH; the official image pins it. Set it to run a newer extractor than your distribution packages — the version in use is in the startup log and in GET /api/admin/system-info. (default: auto-detected)
|
||||
|
||||
# LLM_TIMEOUT_MS=900000 # Ceiling (ms) for one AI-parsing call before it is abandoned. One value for every provider, and the underlying HTTP client is held to it too. Generous by default so heavier parsing work fits without a code change; lower it on a cloud provider to fail fast. (default: 900000)
|
||||
# LLM_MAX_OUTPUT_TOKENS=8192 # Longest answer (tokens) one AI-parsing call may ask for. Raise it when a document with many bookings comes back cut off; keep prompt plus cap inside your model's context. (default: 4096 for OpenAI-compatible, 8192 for Anthropic)
|
||||
|
||||
# VAPID_PUBLIC_KEY= # Web Push: the public half of the key pair browsers subscribe with (base64url, 65 bytes). Leave both halves unset and TREK generates a pair on first start and keeps it in the database, so it travels with every backup. Set both to use a pair of your own, e.g. printed by `npx web-push generate-vapid-keys`. Changing the pair later means every device has to turn push on again.
|
||||
# VAPID_PRIVATE_KEY= # Web Push: the private half (base64url, 32 bytes). Only used together with VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns push off with an error in the log until the pair is fixed. A pair stored in the database is not used in its place, and every device stays registered.
|
||||
|
||||
@@ -165,7 +165,7 @@ ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
|
||||
TREK_DB_PRE_MIGRATE_SNAPSHOT(_KEEP) (read when a migration run starts; the
|
||||
legacy-upgrade suites set it per file),
|
||||
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check),
|
||||
LLM_TIMEOUT_MS, NOMINATIM_URL, VAPID_* (resolved per use by the Web Push key
|
||||
LLM_TIMEOUT_MS, LLM_MAX_OUTPUT_TOKENS, NOMINATIM_URL, VAPID_* (resolved per use by the Web Push key
|
||||
service, which the notification suites build without the container).
|
||||
|
||||
## Exemptions — raw `process.env` stays
|
||||
|
||||
@@ -295,6 +295,13 @@ export function deriveIntegrations(raw: RawEnv) {
|
||||
* rejects a fractional value.
|
||||
*/
|
||||
llmTimeoutMs: Math.floor(positiveNumberOr(raw.LLM_TIMEOUT_MS, 900_000)),
|
||||
/**
|
||||
* How long one AI-parsing answer may get. Unset leaves each client the cap it
|
||||
* has always sent (4096 to OpenAI-compatible servers, 8192 to Anthropic): a
|
||||
* bigger default would make the server of a small local model refuse every
|
||||
* request whose prompt plus cap overruns its context (#2677).
|
||||
*/
|
||||
llmMaxOutputTokens: positiveIntOr(raw.LLM_MAX_OUTPUT_TOKENS, 0) || undefined,
|
||||
// TRANSIT_API_URL, KITINERARY_EXTRACTOR_PATH and PATH are boot-stable and
|
||||
// read only by Nest providers, so the transitConfig and kitineraryConfig
|
||||
// tokens own them (boot-derive.ts).
|
||||
|
||||
@@ -144,6 +144,8 @@ export const envSchema = z.object({
|
||||
// 2^31-1 makes Node clamp the delay to 1 ms and do the same. Both refuse at
|
||||
// boot rather than degrading into a timeout of zero.
|
||||
LLM_TIMEOUT_MS: integer(1, 2_147_483_647, 'must be a whole number of milliseconds between 1 and 2147483647'),
|
||||
// Below 256 not even one reservation fits, so a value that small is a typo, not a choice.
|
||||
LLM_MAX_OUTPUT_TOKENS: integer(256, 1_000_000, 'must be a whole number of tokens between 256 and 1000000'),
|
||||
KITINERARY_EXTRACTOR_PATH: anyString,
|
||||
// The OS search path. Not configuration anybody sets for TREK — it is here so
|
||||
// the kitinerary probe can resolve its binary to an absolute path itself
|
||||
|
||||
@@ -241,6 +241,21 @@ export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {
|
||||
return rows as { entry_date: string; m: number }[];
|
||||
}
|
||||
|
||||
/** One day's entries in timeline order, with what placing a timed entry among them reads (#2679). */
|
||||
async listDayOrder(
|
||||
journeyId: number,
|
||||
entryDate: string,
|
||||
): Promise<{ id: number; entry_time: string | null; sort_order: number | null }[]> {
|
||||
return await this.kysely<JourneyEntriesDateSortKyselyDB>()
|
||||
.selectFrom('journey_entries')
|
||||
.select(['id', 'entry_time', 'sort_order'])
|
||||
.where('journey_id', '=', journeyId)
|
||||
.where('entry_date', '=', entryDate)
|
||||
.orderBy('sort_order', 'asc')
|
||||
.orderBy('id', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** JG42 — `onPlaceCreated`'s next-order lookup: `SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?`. The `?? -1` fallback stays the SERVICE's, unchanged (same text as Task 2's own JG74). */
|
||||
async maxSortOrderForDate(journeyId: number, entryDate: string): Promise<number | null> {
|
||||
const row = await this.kysely<JourneyEntriesDateSortKyselyDB>()
|
||||
|
||||
@@ -135,3 +135,8 @@ export function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] {
|
||||
export function parseBackupCodeHashes(raw: string | null | undefined): string[] {
|
||||
return decodeJson(USER_MFA_BACKUP_CODES, raw).filter((v): v is string => typeof v === 'string');
|
||||
}
|
||||
|
||||
/** Where `plaintext` sits among a user's stored backup-code hashes, or -1 when it is none of them. */
|
||||
export function backupCodeIndex(plaintext: string, raw: string | null | undefined): number {
|
||||
return parseBackupCodeHashes(raw).findIndex((hash) => matchBackupCode(plaintext, hash));
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
generateBackupCodes,
|
||||
hashBackupCodeBcrypt,
|
||||
mask_stored_api_key,
|
||||
backupCodeIndex,
|
||||
matchBackupCode,
|
||||
parseBackupCodeHashes,
|
||||
stripUserForClient,
|
||||
@@ -871,9 +872,10 @@ export class AuthService {
|
||||
throw new DomainError(401, 'Incorrect password');
|
||||
}
|
||||
const secret = decryptMfaSecret(user.mfa_secret);
|
||||
const tokenStr = String(code).replace(/\s/g, '');
|
||||
const ok = authenticator.verify({ token: tokenStr, secret });
|
||||
if (!ok) {
|
||||
const tokenStr = String(code).trim();
|
||||
const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
|
||||
// A backup code proves the second factor as at login; disabling drops them all (#2665).
|
||||
if (!okTotp && backupCodeIndex(tokenStr, user.mfa_backup_codes) === -1) {
|
||||
throw new DomainError(401, 'Invalid verification code');
|
||||
}
|
||||
// Every other session ends with the second factor; this one just proved both.
|
||||
@@ -1053,10 +1055,8 @@ export class AuthService {
|
||||
const secret = decryptMfaSecret(user.mfa_secret);
|
||||
const okTotp = authenticator.verify({ token: supplied.replace(/\s/g, ''), secret });
|
||||
if (!okTotp) {
|
||||
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
|
||||
const idx = hashes.findIndex((h) => matchBackupCode(supplied, h));
|
||||
if (idx === -1) throw new DomainError(401, 'Invalid MFA code');
|
||||
backupCodeConsumedIndex = idx;
|
||||
backupCodeConsumedIndex = backupCodeIndex(supplied, user.mfa_backup_codes);
|
||||
if (backupCodeConsumedIndex === -1) throw new DomainError(401, 'Invalid MFA code');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -780,8 +780,8 @@ export class SynologyDriveClient {
|
||||
api: 'SYNO.API.Auth',
|
||||
version: '6',
|
||||
method: 'login',
|
||||
account: JSON.stringify(creds.username),
|
||||
passwd: JSON.stringify(creds.password),
|
||||
// Plain: auth.cgi reads no JSON, a quoted account was refused as `"trek"` (#2666).
|
||||
...{ account: creds.username, passwd: creds.password },
|
||||
session: SESSION_NAME,
|
||||
format: 'sid',
|
||||
client: 'browser',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { todayUtc } from '@trek/shared';
|
||||
import { TrekPhotoRegistrationService } from '../photos/trek-photo-registration.service';
|
||||
import { getCountryFromCoords } from '../atlas/atlas-geo';
|
||||
import { computeJourneyStats, type StatsInputPoint } from './journey-stats';
|
||||
import { newEntrySortOrder, placeMovedEntry } from './journey-entry-order';
|
||||
import { Journeys } from '../../db/entities/Journeys.entity';
|
||||
import type { JourneysRepository } from '../../db/repositories/Journeys.repository';
|
||||
import { JourneyContributors } from '../../db/entities/JourneyContributors.entity';
|
||||
@@ -1209,10 +1210,8 @@ export class JourneyDomainService {
|
||||
? JSON.stringify(data.pros_cons)
|
||||
: null;
|
||||
|
||||
// The next position and the row that takes it in one transaction, so two
|
||||
// entries added at once cannot both read the same MAX (JG74).
|
||||
// Position and row in one transaction, so two adds cannot read the same MAX (JG74, #2679).
|
||||
const insertedId = await this.uow.transactional(async () => {
|
||||
const maxOrder = await this.entriesRepo.maxSortOrderForDate(journeyId, data.entry_date);
|
||||
return await this.entriesRepo.insertEntry({
|
||||
journey_id: journeyId,
|
||||
author_id: userId,
|
||||
@@ -1230,7 +1229,7 @@ export class JourneyDomainService {
|
||||
tags: data.tags?.length ? JSON.stringify(data.tags) : null,
|
||||
pros_cons: prosConsJson,
|
||||
visibility: data.visibility || 'private',
|
||||
sort_order: (maxOrder ?? -1) + 1,
|
||||
sort_order: await newEntrySortOrder(this.entriesRepo, journeyId, data.entry_date, data.entry_time, now),
|
||||
is_draft: data.is_draft ? 1 : 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -1356,6 +1355,7 @@ export class JourneyDomainService {
|
||||
if (Object.keys(patch).length === 1) return decodeEntryRow(entry);
|
||||
|
||||
await this.uow.transactional(async () => {
|
||||
await placeMovedEntry(this.entriesRepo, entry, patch, this.ts());
|
||||
await this.entriesRepo.updateFields(entryId, patch);
|
||||
await this.journeysRepo.updateFields(entry.journey_id, { updated_at: this.ts() }); // touch the journey
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { JourneyEntriesRepository } from '../../db/repositories/JourneyEntries.repository';
|
||||
|
||||
/** "HH:MM" (seconds ignored) as minutes past midnight, or null for anything else. */
|
||||
function minutesOf(time: string | null | undefined): number | null {
|
||||
const match = /^(\d{1,2}):(\d{2})/.exec(time?.trim() ?? '');
|
||||
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an entry at `time` belongs among its day's entries, given in their
|
||||
* current order: right before the first one that starts later. Entries
|
||||
* without a time are no anchor either way, and an unreadable `time` goes last.
|
||||
*/
|
||||
export function chronologicalSlot(day: readonly { entry_time: string | null }[], time: string): number {
|
||||
const at = minutesOf(time);
|
||||
if (at == null) return day.length;
|
||||
const later = day.findIndex((e) => {
|
||||
const other = minutesOf(e.entry_time);
|
||||
return other != null && other > at;
|
||||
});
|
||||
return later === -1 ? day.length : later;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes room for a timed entry in its day and returns the sort_order it takes.
|
||||
*
|
||||
* A new entry used to go to the end of its day whatever its time, so writing
|
||||
* up the morning after the evening left the morning last (#2679). It now lands
|
||||
* before the first entry that starts later; the order of everything else,
|
||||
* dragged by hand or not, stays as it was. Run it inside the write's
|
||||
* transaction, so the room and the entry that takes it land together.
|
||||
*/
|
||||
export async function makeRoomAtTime(
|
||||
entries: JourneyEntriesRepository,
|
||||
journeyId: number,
|
||||
date: string,
|
||||
time: string,
|
||||
now: number,
|
||||
exceptId?: number,
|
||||
): Promise<number> {
|
||||
const day = (await entries.listDayOrder(journeyId, date)).filter((e) => e.id !== exceptId);
|
||||
const slot = chronologicalSlot(day, time);
|
||||
const moves = day
|
||||
.map((e, index) => ({ id: e.id, from: e.sort_order, to: index < slot ? index : index + 1 }))
|
||||
.filter((m) => m.from !== m.to);
|
||||
await Promise.all(moves.map((m) => entries.updateSortOrder(m.id, m.to, now)));
|
||||
return slot;
|
||||
}
|
||||
|
||||
/** The sort_order a new entry takes: where its time puts it in its day, last when it has none. */
|
||||
export async function newEntrySortOrder(
|
||||
entries: JourneyEntriesRepository,
|
||||
journeyId: number,
|
||||
date: string,
|
||||
time: string | null | undefined,
|
||||
now: number,
|
||||
): Promise<number> {
|
||||
if (time) return await makeRoomAtTime(entries, journeyId, date, time, now);
|
||||
return ((await entries.maxSortOrderForDate(journeyId, date)) ?? -1) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an edited entry to where its new time or day puts it, by giving the patch
|
||||
* the sort_order it takes. Left alone when neither changed, so a save keeps an
|
||||
* entry where it was dragged, and when the caller sent a sort_order of its own.
|
||||
*/
|
||||
export async function placeMovedEntry(
|
||||
entries: JourneyEntriesRepository,
|
||||
entry: { id: number; journey_id: number; entry_date: string; entry_time?: string | null },
|
||||
patch: { entry_date?: string; entry_time?: string | null; sort_order?: number },
|
||||
now: number,
|
||||
): Promise<void> {
|
||||
if (patch.sort_order !== undefined) return;
|
||||
const date = patch.entry_date ?? entry.entry_date;
|
||||
const time = patch.entry_time !== undefined ? patch.entry_time : entry.entry_time;
|
||||
if (!time || (date === entry.entry_date && time === entry.entry_time)) return;
|
||||
patch.sort_order = await makeRoomAtTime(entries, entry.journey_id, date, time, now, entry.id);
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.in
|
||||
import { isContextOverflow, LlmContextOverflowError } from './context-overflow';
|
||||
import { UnreadableLlmResponse } from './openai-compatible.client';
|
||||
|
||||
const MAX_TOKENS = 8192;
|
||||
/** The answer cap when LLM_MAX_OUTPUT_TOKENS is unset. */
|
||||
const DEFAULT_MAX_TOKENS = 8192;
|
||||
const ANTHROPIC_VERSION = '2023-06-01';
|
||||
|
||||
/**
|
||||
@@ -43,9 +44,10 @@ export class AnthropicClient implements LlmExtractionClient {
|
||||
text: input.text ? `${userText}\n\n${input.text}` : userText,
|
||||
});
|
||||
|
||||
const maxTokens = readEnv().integrations.llmMaxOutputTokens ?? DEFAULT_MAX_TOKENS;
|
||||
const body = {
|
||||
model: input.model,
|
||||
max_tokens: MAX_TOKENS,
|
||||
max_tokens: maxTokens,
|
||||
system: input.prompt,
|
||||
tools: [
|
||||
{
|
||||
@@ -101,7 +103,7 @@ export class AnthropicClient implements LlmExtractionClient {
|
||||
// log: the #2375 symptom, on the provider the dropdown offers first.
|
||||
if (data.stop_reason === 'max_tokens') {
|
||||
throw new UnreadableLlmResponse(
|
||||
`the answer was cut off at the ${MAX_TOKENS}-token limit — the document is too long to extract in one pass`,
|
||||
`the answer was cut off at the ${maxTokens}-token limit — raise LLM_MAX_OUTPUT_TOKENS or import fewer bookings per document`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.in
|
||||
import { isContextOverflow, LlmContextOverflowError } from './context-overflow';
|
||||
import { isNuExtractModel, buildNuExtractUserText, nuExtractToKiReservations } from './nuextract';
|
||||
|
||||
const MAX_TOKENS = 4096;
|
||||
/** The answer cap when LLM_MAX_OUTPUT_TOKENS is unset. */
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
|
||||
/**
|
||||
* A response the client could not read at all: prose where JSON was asked for,
|
||||
@@ -106,10 +107,11 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
|
||||
// local server (Ollama/vLLM/llama.cpp), but newer OpenAI models reject it
|
||||
// with a 400 and demand `max_completion_tokens`. Start with the broadly
|
||||
// supported spelling and swap on that specific rejection (#1760).
|
||||
const maxTokens = readEnv().integrations.llmMaxOutputTokens ?? DEFAULT_MAX_TOKENS;
|
||||
const buildBody = (shape: RequestShape) => {
|
||||
const baseBody = {
|
||||
model: input.model,
|
||||
[shape.tokenParam]: MAX_TOKENS,
|
||||
[shape.tokenParam]: maxTokens,
|
||||
// Extraction is a deterministic task — Ollama defaults to 0.7, which makes
|
||||
// small models (NuExtract) drop fields or return empty. Pin to 0, and only
|
||||
// leave it out once a server has explicitly rejected the parameter (#2262).
|
||||
@@ -193,8 +195,16 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
|
||||
console.debug(`[DEBUG] LLM answered with response_format=${sentFormat(shape, nuextract)}`);
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
choices?: { message?: { content?: string }; finish_reason?: string }[];
|
||||
};
|
||||
// A run that hits the cap stops mid-list, and the fragment either reads as
|
||||
// nothing or not at all: "no reservations" for a document full of them, with
|
||||
// no hint that a setting would fix it (#2677). Same rule as the Anthropic client.
|
||||
if (data.choices?.[0]?.finish_reason === 'length') {
|
||||
throw new UnreadableLlmResponse(
|
||||
`the answer was cut off at the ${maxTokens}-token limit — raise LLM_MAX_OUTPUT_TOKENS or import fewer bookings per document`,
|
||||
);
|
||||
}
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
return nuextract ? parseNuExtract(content) : parseReservations(content, rootKey);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { buildOsmDetails, parsePoiCategories, samePlace, toApiLang } from './map
|
||||
import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client';
|
||||
import {
|
||||
POI_CATEGORY_TO_TREK,
|
||||
indexRowTerm,
|
||||
isOsmHit,
|
||||
osmPlaceId,
|
||||
toPlaceRecord,
|
||||
@@ -55,22 +56,15 @@ export function indexPoiPlan(category: string, bbox: PoiBbox, limit: number): In
|
||||
// by Overpass, and a mixed answer would silently drop it.
|
||||
const indexKnowsAll = wanted.length > 0 && wanted.every((key) => POI_CATEGORY_TO_TREK[key]?.length);
|
||||
if (!indexKnowsAll) return null;
|
||||
// The index matches a term as a SUBSTRING of `category` and `category_path`
|
||||
// (see POI_CATEGORY_TO_TREK), so an exact lookup misses every leaf that is
|
||||
// not literally a term: `italian_restaurant` is a hit for `restaurant` and
|
||||
// finds nothing here. Falling through to wanted[0] then labelled it with
|
||||
// whichever pill the user happened to tap first, and the corridor panel
|
||||
// groups and colours on exactly that field, so a trattoria came back as a
|
||||
// petrol station. Longest match wins, because `fast_food` must not lose to
|
||||
// `food` when both are terms of different categories.
|
||||
// A row belongs to the term whose path it lies under, not to one it is merely
|
||||
// spelled like: the leaf alone is rarely a term (`italian_restaurant` sits
|
||||
// under `food_and_drink>restaurant`), and falling through to wanted[0] then
|
||||
// labelled it with whichever pill the user tapped first, so a trattoria came
|
||||
// back as a petrol station. The most specific term wins, so a water park
|
||||
// answers the activity pill rather than the park it sits under.
|
||||
const labelFor = (leaf: string | null, path: string | null): string | undefined => {
|
||||
const haystack = `${leaf ?? ''} ${path ?? ''}`;
|
||||
let best: string | undefined;
|
||||
for (const term of categoryOfTerm.keys()) {
|
||||
if (!haystack.includes(term)) continue;
|
||||
if (best === undefined || term.length > best.length) best = term;
|
||||
}
|
||||
return best === undefined ? undefined : categoryOfTerm.get(best);
|
||||
const term = indexRowTerm(categoryOfTerm.keys(), leaf, path);
|
||||
return term === undefined ? undefined : categoryOfTerm.get(term);
|
||||
};
|
||||
const lat = (bbox.south + bbox.north) / 2;
|
||||
const lng = (bbox.west + bbox.east) / 2;
|
||||
@@ -109,7 +103,7 @@ export function indexPoiAnswer(
|
||||
): PoiSearchResult {
|
||||
const { wanted, labelFor, cap, radius, reach } = plan;
|
||||
return {
|
||||
pois: found.map((p) => ({
|
||||
pois: belonging(found, plan).map((p) => ({
|
||||
osm_id: `gers:${p.gers}`,
|
||||
name: p.name,
|
||||
lat: p.lat,
|
||||
@@ -138,6 +132,8 @@ export function indexPoiAnswer(
|
||||
source: 'trek-places' as const,
|
||||
})),
|
||||
source: 'trek-places' as const,
|
||||
// The page the index sent, not what was left of it: a full page means more
|
||||
// places, however many of them this one had to leave out.
|
||||
truncated: found.length >= cap,
|
||||
// A wide viewport is narrowed here too, and the caller is told so
|
||||
// for the same reason the Overpass path tells it.
|
||||
@@ -145,6 +141,18 @@ export function indexPoiAnswer(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows that belong to a pill the request asked for. The index matches its
|
||||
* terms inside the category text, so a near miss can still come back, and it
|
||||
* is left out here (#2663). A row the index holds without any category cannot
|
||||
* be told either way and stays, as it always has.
|
||||
*/
|
||||
export function belonging<T extends TrekPlace & { categoryPath?: string | null }>(found: T[], plan: IndexPoiPlan): T[] {
|
||||
return found.filter(
|
||||
(p) => (!p.category && !p.categoryPath) || plan.labelFor(p.category ?? null, p.categoryPath ?? null) !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const regionNamesByLang = new Map<string, Intl.DisplayNames | null>();
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AppSettingsRepository } from '../../db/repositories/AppSettings.re
|
||||
import type { GeoLane } from '../geo/nominatim.client';
|
||||
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
|
||||
import type { ApiKeySource } from '../settings/instance-api-keys';
|
||||
import { indexPoiAnswer, indexPoiPlan, indexSuggestions } from './maps-index.helpers';
|
||||
import { belonging, indexPoiAnswer, indexPoiPlan, indexSuggestions } from './maps-index.helpers';
|
||||
import { NEARBY_DEFAULT_LIMIT, NEARBY_DEFAULT_RADIUS_M, nearbyCacheKey, nearestFirst } from './maps-nearby.helpers';
|
||||
import { MapsUrlResolver, type ResolvedMapsUrl } from './maps-url.resolver';
|
||||
import { toApiLang, mergeSearchResults } from './maps.helpers';
|
||||
@@ -214,7 +214,8 @@ export class MapsService {
|
||||
limit: plan.cap,
|
||||
category: plan.terms.join(','),
|
||||
});
|
||||
if (found.length > 0) return indexPoiAnswer(found, plan);
|
||||
// A page of near misses only is a miss too, not an empty answer.
|
||||
if (belonging(found, plan).length > 0) return indexPoiAnswer(found, plan);
|
||||
} catch (err: unknown) {
|
||||
console.warn('TREK Places nearby failed, falling back:', (err as Error).message);
|
||||
}
|
||||
|
||||
@@ -345,31 +345,70 @@ export function indexHitsOnly(hits: TrekSearchHit[]): TrekPlace[] {
|
||||
*
|
||||
* Deliberately a hand-written map rather than a guess at runtime: the two
|
||||
* taxonomies do not line up, and a pill that quietly returns the wrong kind of
|
||||
* place is worse than one that returns none. Each entry is a substring matched
|
||||
* against `category` and `category_path`, so `eat_and_drink>cafe` answers the
|
||||
* cafe pill without listing every leaf.
|
||||
* place is worse than one that returns none. Each entry is a category path, and
|
||||
* a place belongs to it when its own path is that path or lies under it, so
|
||||
* `food_and_drink>restaurant` answers the italian restaurant two levels down.
|
||||
*
|
||||
* Paths, not bare leaf names: the index matches a term anywhere inside
|
||||
* `category` and `category_path`, and a bare `park` brought car parks, `garden`
|
||||
* furniture stores and `bar` barbers (#2663). A full path does not occur inside
|
||||
* an unrelated one, and {@link indexRowTerm} drops whatever still slips through.
|
||||
* Overture renames a branch now and then; a path that stops existing answers
|
||||
* nothing, and the pill falls back to Overpass instead of answering wrong.
|
||||
*/
|
||||
export const POI_CATEGORY_TO_TREK: Record<string, string[]> = {
|
||||
restaurant: ['restaurant', 'casual_eatery', 'fast_food'],
|
||||
cafe: ['cafe', 'coffee_shop'],
|
||||
bar: ['bar', 'pub', 'nightclub', 'brewery'],
|
||||
hotel: ['hotel', 'lodging', 'hostel', 'motel', 'bed_and_breakfast'],
|
||||
sights: ['historic_site', 'landmark_and_historical_building', 'monument', 'castle', 'tourist_attraction'],
|
||||
museum: ['museum', 'art_gallery', 'performing_arts', 'theater'],
|
||||
nature: ['park', 'garden', 'beach', 'nature_preserve', 'mountain'],
|
||||
activity: ['amusement_park', 'zoo', 'aquarium', 'water_park', 'theme_park'],
|
||||
shopping: ['shopping', 'shopping_center', 'department_store', 'market'],
|
||||
supermarket: ['grocery_store', 'food_and_beverage_store', 'convenience_store', 'supermarket'],
|
||||
restaurant: ['food_and_drink>restaurant', 'food_and_drink>casual_eatery'],
|
||||
cafe: ['food_and_drink>casual_eatery>cafe', 'food_and_drink>non_alcoholic_beverage_venue>coffee_shop'],
|
||||
bar: ['food_and_drink>alcoholic_beverage_venue', 'arts_and_entertainment>nightlife_venue'],
|
||||
hotel: ['lodging'],
|
||||
sights: ['cultural_and_historic>historic_site', 'cultural_and_historic>memorial_site>monument'],
|
||||
museum: [
|
||||
'arts_and_entertainment>museum',
|
||||
'arts_and_entertainment>arts_and_crafts_space>art_gallery',
|
||||
'arts_and_entertainment>performing_arts_venue',
|
||||
'arts_and_entertainment>movie_theater',
|
||||
],
|
||||
nature: [
|
||||
'sports_and_recreation>park',
|
||||
'geographic_entities>built_feature>garden',
|
||||
'geographic_entities>land_feature>beach',
|
||||
'geographic_entities>land_feature>nature_reserve',
|
||||
'geographic_entities>land_feature>mountain',
|
||||
],
|
||||
activity: [
|
||||
'arts_and_entertainment>amusement_attraction>amusement_park',
|
||||
'arts_and_entertainment>animal_attraction>zoo',
|
||||
'arts_and_entertainment>animal_attraction>aquarium',
|
||||
'sports_and_recreation>park>water_park',
|
||||
],
|
||||
shopping: ['shopping'],
|
||||
supermarket: ['shopping>food_and_beverage_store', 'shopping>convenience_store'],
|
||||
// What a drive needs rather than what a city visit does (#1797). The corridor
|
||||
// search asks for these by name, and without them every corridor query fell
|
||||
// through to Overpass — the one path where that hurts most, because a single
|
||||
// search fans out over sixteen boxes.
|
||||
fuel: ['gas_station', 'fueling_station'],
|
||||
charging: ['ev_charging_station'],
|
||||
rest_area: ['rest_stop'],
|
||||
campsite: ['campground', 'rv_park'],
|
||||
fuel: ['travel_and_transportation>fueling_station>gas_station'],
|
||||
charging: ['travel_and_transportation>fueling_station>ev_charging_station'],
|
||||
rest_area: ['travel_and_transportation>ground_transport_facility_or_service>road_structures_and_service>rest_stop'],
|
||||
campsite: ['lodging>campground', 'lodging>rv_park'],
|
||||
};
|
||||
|
||||
/**
|
||||
* The term of {@link POI_CATEGORY_TO_TREK} a row belongs to, the most specific
|
||||
* one when several fit, or undefined when none does. A row is judged by its path
|
||||
* where it has one and by its leaf where the index left the path empty.
|
||||
*/
|
||||
export function indexRowTerm(terms: Iterable<string>, leaf: string | null, path: string | null): string | undefined {
|
||||
let best: string | undefined;
|
||||
for (const term of terms) {
|
||||
const fits = path
|
||||
? path === term || path.startsWith(`${term}>`)
|
||||
: !!leaf && leaf === term.slice(term.lastIndexOf('>') + 1);
|
||||
if (fits && (best === undefined || term.length > best.length)) best = term;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface TrekNearbyPlace extends TrekPlace {
|
||||
distanceMetres: number;
|
||||
}
|
||||
|
||||
@@ -517,6 +517,52 @@ describe('MFA', () => {
|
||||
expect(disableRes.status).toBe(200);
|
||||
expect(disableRes.body.mfa_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('AUTH-019 — disable MFA with password and unused backup code', async () => {
|
||||
const { hashBackupCodeBcrypt } = await import('../../src/nest/auth/auth.helpers');
|
||||
const { user, password } = createUserWithMfa(testDb);
|
||||
await updateRows(
|
||||
orm,
|
||||
Users,
|
||||
{ id: user.id },
|
||||
{
|
||||
mfa_backup_codes: JSON.stringify([hashBackupCodeBcrypt('A1B2-C3D4')]),
|
||||
},
|
||||
);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mfa/disable')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ password, code: 'a1b2-c3d4' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfa_enabled).toBe(false);
|
||||
const updated = await rowOf(Users, { id: user.id });
|
||||
expect(updated?.mfa_enabled).toBe(0);
|
||||
expect(updated?.mfa_secret).toBeNull();
|
||||
expect(updated?.mfa_backup_codes).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-019 — reject invalid recovery code and incorrect password', async () => {
|
||||
const { hashBackupCodeBcrypt } = await import('../../src/nest/auth/auth.helpers');
|
||||
const { user, password } = createUserWithMfa(testDb);
|
||||
await updateRows(
|
||||
orm,
|
||||
Users,
|
||||
{ id: user.id },
|
||||
{
|
||||
mfa_backup_codes: JSON.stringify([hashBackupCodeBcrypt('A1B2-C3D4')]),
|
||||
},
|
||||
);
|
||||
const badCode = await request(app)
|
||||
.post('/api/auth/mfa/disable')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ password, code: 'FFFF-FFFF' });
|
||||
expect(badCode.status).toBe(401);
|
||||
const badPassword = await request(app)
|
||||
.post('/api/auth/mfa/disable')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ password: 'invalid-password', code: 'A1B2-C3D4' });
|
||||
expect(badPassword.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -261,6 +261,59 @@ describe('Journey entries', () => {
|
||||
expect(res.body.story).toBe('Now with a story');
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-010b — a timed entry goes where its time puts it in the day, an untimed one last (#2679)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const post = (title: string, entry_time?: string) =>
|
||||
request(app)
|
||||
.post(`/api/journeys/${journey.id}/entries`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title, entry_date: '2026-04-01', ...(entry_time ? { entry_time } : {}) });
|
||||
|
||||
await post('two', '02:00');
|
||||
await post('untimed');
|
||||
await post('one', '01:00');
|
||||
await post('three', '03:00');
|
||||
|
||||
const res = await request(app).get(`/api/journeys/${journey.id}`).set('Cookie', authCookie(user.id));
|
||||
const titles = res.body.entries.map((e: { title: string }) => e.title);
|
||||
expect(titles).toEqual(['one', 'two', 'untimed', 'three']);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-011b — a new time moves the entry, an unchanged one keeps a hand-made order (#2679)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const post = async (title: string, entry_time: string) =>
|
||||
(
|
||||
await request(app)
|
||||
.post(`/api/journeys/${journey.id}/entries`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title, entry_date: '2026-04-01', entry_time })
|
||||
).body.id as number;
|
||||
const ids = [await post('morning', '08:00'), await post('noon', '12:00'), await post('evening', '19:00')];
|
||||
const titles = async () =>
|
||||
(await request(app).get(`/api/journeys/${journey.id}`).set('Cookie', authCookie(user.id))).body.entries.map(
|
||||
(e: { title: string }) => e.title,
|
||||
);
|
||||
|
||||
await request(app)
|
||||
.patch(`/api/journeys/entries/${ids[2]}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ entry_time: '07:00' });
|
||||
expect(await titles()).toEqual(['evening', 'morning', 'noon']);
|
||||
|
||||
// Dragged out of time order, then saved again with the same time: it stays put.
|
||||
await request(app)
|
||||
.put(`/api/journeys/${journey.id}/entries/reorder`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ orderedIds: [ids[0], ids[1], ids[2]] });
|
||||
await request(app)
|
||||
.patch(`/api/journeys/entries/${ids[2]}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'early', entry_time: '07:00' });
|
||||
expect(await titles()).toEqual(['morning', 'noon', 'early']);
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-012 — DELETE /api/journeys/entries/:id deletes entry', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
@@ -248,6 +248,12 @@ describe('deriveIntegrations', () => {
|
||||
expect(deriveIntegrations({ LLM_TIMEOUT_MS: '-1' }).llmTimeoutMs).toBe(900_000);
|
||||
expect(deriveIntegrations({ LLM_TIMEOUT_MS: '60000.5' }).llmTimeoutMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it('leaves the LLM answer cap to each client unless one is set (#2677)', () => {
|
||||
expect(deriveIntegrations({}).llmMaxOutputTokens).toBeUndefined();
|
||||
expect(deriveIntegrations({ LLM_MAX_OUTPUT_TOKENS: '' }).llmMaxOutputTokens).toBeUndefined();
|
||||
expect(deriveIntegrations({ LLM_MAX_OUTPUT_TOKENS: '16384' }).llmMaxOutputTokens).toBe(16384);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveTransit (transitConfig token)', () => {
|
||||
|
||||
@@ -74,6 +74,14 @@ describe('validateEnvAtBoot', () => {
|
||||
expect(() => validateEnvAtBoot({ LLM_TIMEOUT_MS: '' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('refuses an LLM answer cap that is no whole token count or too small to hold a booking (#2677)', () => {
|
||||
expect(() => validateEnvAtBoot({ LLM_MAX_OUTPUT_TOKENS: '8192.5' })).toThrow();
|
||||
expect(() => validateEnvAtBoot({ LLM_MAX_OUTPUT_TOKENS: '100' })).toThrow();
|
||||
expect(() => validateEnvAtBoot({ LLM_MAX_OUTPUT_TOKENS: 'lots' })).toThrow();
|
||||
expect(() => validateEnvAtBoot({ LLM_MAX_OUTPUT_TOKENS: '16384' })).not.toThrow();
|
||||
expect(() => validateEnvAtBoot({ LLM_MAX_OUTPUT_TOKENS: '' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('refuses an ALLOW_LINK_LOCAL_IPS entry that is not a listable link-local address', () => {
|
||||
// A metadata address would be dropped silently at runtime; saying so at boot
|
||||
// is the difference between an OIDC login that works and a puzzling one.
|
||||
|
||||
@@ -552,7 +552,8 @@ describe('Tool: search_pois', () => {
|
||||
const [lat, lng, opts] = trekNearbyMock.mock.calls[0];
|
||||
expect(lat).toBeCloseTo(48.86, 5);
|
||||
expect(lng).toBeCloseTo(2.345, 5);
|
||||
expect(opts?.category).toBe('restaurant,casual_eatery,fast_food');
|
||||
// Overture paths, so a pill cannot catch what is only spelled like it (#2663).
|
||||
expect(opts?.category).toBe('food_and_drink>restaurant,food_and_drink>casual_eatery');
|
||||
// Sixty per category, the same allowance the Overpass path spends, rather
|
||||
// than a number this branch made up for itself.
|
||||
expect(opts?.limit).toBe(60);
|
||||
|
||||
@@ -286,7 +286,7 @@ describe('SynologyDriveDocumentProvider: capabilities', () => {
|
||||
// ── login and session ────────────────────────────────────────────────────────
|
||||
|
||||
describe('SynologyDriveDocumentProvider: login', () => {
|
||||
it('SYNO-PROVIDER-010: logs in on auth.cgi with JSON-quoted values and asks for a CSRF token', async () => {
|
||||
it('SYNO-PROVIDER-010: logs in on auth.cgi with plain values and asks for a CSRF token (#2666)', async () => {
|
||||
route({ 'SYNO.FileStation.Info:get': ok({ hostname: 'nas' }) });
|
||||
const result = await provider.probe(connection());
|
||||
|
||||
@@ -296,8 +296,9 @@ describe('SynologyDriveDocumentProvider: login', () => {
|
||||
api: 'SYNO.API.Auth',
|
||||
version: '6',
|
||||
method: 'login',
|
||||
account: '"trek"',
|
||||
passwd: '"nas-secret"',
|
||||
// auth.cgi does not read JSON: a quoted account is a user named "trek" with quotes.
|
||||
account: 'trek',
|
||||
passwd: 'nas-secret',
|
||||
session: 'FileStation',
|
||||
format: 'sid',
|
||||
enable_syno_token: 'yes',
|
||||
@@ -643,7 +644,7 @@ describe('SynologyDriveDocumentProvider: device tokens', () => {
|
||||
store.password = 'rotated';
|
||||
restart();
|
||||
await provider.listScopes(saved(store));
|
||||
expect(logins()[0].params).toMatchObject({ device_id: 'DEVICE-111111', passwd: '"rotated"' });
|
||||
expect(logins()[0].params).toMatchObject({ device_id: 'DEVICE-111111', passwd: 'rotated' });
|
||||
expect(logins()[0].params.otp_code).toBeUndefined();
|
||||
|
||||
delete store.otp_code;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { JourneyEntriesRepository } from '../../../src/db/repositories/JourneyEntries.repository';
|
||||
import {
|
||||
chronologicalSlot,
|
||||
makeRoomAtTime,
|
||||
newEntrySortOrder,
|
||||
placeMovedEntry,
|
||||
} from '../../../src/nest/journey/journey-entry-order';
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
const at = (...times: (string | null)[]) => times.map((entry_time) => ({ entry_time }));
|
||||
|
||||
describe('chronologicalSlot (#2679)', () => {
|
||||
it('goes before the first entry that starts later', () => {
|
||||
expect(chronologicalSlot(at('02:00'), '01:00')).toBe(0);
|
||||
expect(chronologicalSlot(at('01:00', '03:00'), '02:00')).toBe(1);
|
||||
expect(chronologicalSlot(at('01:00', '02:00'), '05:00')).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps an equal time after the one already there', () => {
|
||||
expect(chronologicalSlot(at('09:00', '10:00'), '09:00')).toBe(1);
|
||||
});
|
||||
|
||||
it('passes over entries without a time instead of anchoring on them', () => {
|
||||
expect(chronologicalSlot(at(null, '08:00', null), '07:00')).toBe(1);
|
||||
expect(chronologicalSlot(at(null, null), '07:00')).toBe(2);
|
||||
});
|
||||
|
||||
it('reads seconds and single-digit hours, and puts an unreadable time last', () => {
|
||||
expect(chronologicalSlot(at('9:30:00', '10:00:00'), '09:45')).toBe(1);
|
||||
expect(chronologicalSlot(at('01:00'), 'soon')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeRoomAtTime (#2679)', () => {
|
||||
function repo(day: { id: number; entry_time: string | null; sort_order: number | null }[]) {
|
||||
const updateSortOrder = vi.fn(async () => {});
|
||||
const listDayOrder = vi.fn(async () => day);
|
||||
return { entries: { listDayOrder, updateSortOrder } as unknown as JourneyEntriesRepository, updateSortOrder };
|
||||
}
|
||||
|
||||
it('moves only the entries after the slot, and returns the slot', async () => {
|
||||
const { entries, updateSortOrder } = repo([
|
||||
{ id: 1, entry_time: '08:00', sort_order: 0 },
|
||||
{ id: 2, entry_time: '12:00', sort_order: 1 },
|
||||
{ id: 3, entry_time: '19:00', sort_order: 2 },
|
||||
]);
|
||||
expect(await makeRoomAtTime(entries, 7, '2026-04-01', '10:00', 99)).toBe(1);
|
||||
expect(updateSortOrder.mock.calls).toEqual([
|
||||
[2, 2, 99],
|
||||
[3, 3, 99],
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves the moved entry itself out and closes the gap it leaves', async () => {
|
||||
const { entries, updateSortOrder } = repo([
|
||||
{ id: 1, entry_time: '08:00', sort_order: 0 },
|
||||
{ id: 2, entry_time: '12:00', sort_order: 1 },
|
||||
{ id: 3, entry_time: '19:00', sort_order: 2 },
|
||||
]);
|
||||
expect(await makeRoomAtTime(entries, 7, '2026-04-01', '07:00', 99, 3)).toBe(0);
|
||||
expect(updateSortOrder.mock.calls).toEqual([
|
||||
[1, 1, 99],
|
||||
[2, 2, 99],
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes nothing when the entry goes last on an evenly numbered day', async () => {
|
||||
const { entries, updateSortOrder } = repo([{ id: 1, entry_time: '08:00', sort_order: 0 }]);
|
||||
expect(await makeRoomAtTime(entries, 7, '2026-04-01', '09:00', 99)).toBe(1);
|
||||
expect(updateSortOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('newEntrySortOrder / placeMovedEntry (#2679)', () => {
|
||||
const day = [
|
||||
{ id: 1, entry_time: '08:00', sort_order: 0 },
|
||||
{ id: 2, entry_time: '19:00', sort_order: 1 },
|
||||
];
|
||||
function repo() {
|
||||
const updateSortOrder = vi.fn(async () => {});
|
||||
const entries = {
|
||||
listDayOrder: vi.fn(async () => day),
|
||||
updateSortOrder,
|
||||
maxSortOrderForDate: vi.fn(async () => 4),
|
||||
} as unknown as JourneyEntriesRepository;
|
||||
return { entries, updateSortOrder };
|
||||
}
|
||||
const entry = { id: 2, journey_id: 7, entry_date: '2026-04-01', entry_time: '19:00' };
|
||||
|
||||
it('puts a timed entry by its time and an untimed one last', async () => {
|
||||
expect(await newEntrySortOrder(repo().entries, 7, '2026-04-01', '12:00', 1)).toBe(1);
|
||||
expect(await newEntrySortOrder(repo().entries, 7, '2026-04-01', null, 1)).toBe(5);
|
||||
});
|
||||
|
||||
it('moves an entry whose time changed', async () => {
|
||||
const patch: { entry_time?: string; sort_order?: number } = { entry_time: '07:00' };
|
||||
await placeMovedEntry(repo().entries, entry, patch, 1);
|
||||
expect(patch.sort_order).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves the patch alone for an unchanged time, no time, or a sort_order of its own', async () => {
|
||||
const same: { entry_time?: string; sort_order?: number } = { entry_time: '19:00' };
|
||||
await placeMovedEntry(repo().entries, entry, same, 1);
|
||||
expect('sort_order' in same).toBe(false);
|
||||
|
||||
const cleared: { entry_time?: string | null; sort_order?: number } = { entry_time: null };
|
||||
await placeMovedEntry(repo().entries, entry, cleared, 1);
|
||||
expect('sort_order' in cleared).toBe(false);
|
||||
|
||||
const placed = { entry_time: '07:00', sort_order: 3 };
|
||||
const { entries, updateSortOrder } = repo();
|
||||
await placeMovedEntry(entries, entry, placed, 1);
|
||||
expect(placed.sort_order).toBe(3);
|
||||
expect(updateSortOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,29 @@ describe('OpenAiCompatibleClient', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('caps the answer at 4096 tokens unless LLM_MAX_OUTPUT_TOKENS says otherwise (#2677)', async () => {
|
||||
const fetchFn = mockFetch(() => jsonResponse({ choices: [{ message: { content: '{"reservations":[]}' } }] }));
|
||||
await new OpenAiCompatibleClient().extract(baseInput);
|
||||
expect(JSON.parse(fetchFn.mock.calls[0][1].body as string).max_tokens).toBe(4096);
|
||||
|
||||
vi.stubEnv('LLM_MAX_OUTPUT_TOKENS', '16384');
|
||||
try {
|
||||
await new OpenAiCompatibleClient().extract(baseInput);
|
||||
expect(JSON.parse(fetchFn.mock.calls[1][1].body as string).max_tokens).toBe(16384);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when the answer was cut off at the token cap instead of reading it as empty (#2677)', async () => {
|
||||
mockFetch(() =>
|
||||
jsonResponse({ choices: [{ message: { content: '{"reservations":[{"@type":"Flig' }, finish_reason: 'length' }] }),
|
||||
);
|
||||
await expect(new OpenAiCompatibleClient().extract(baseInput)).rejects.toThrow(
|
||||
/cut off at the 4096-token limit — raise LLM_MAX_OUTPUT_TOKENS/,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads a single reservation the model answered on its own', async () => {
|
||||
mockFetch(() =>
|
||||
jsonResponse({ choices: [{ message: { content: '{"@type":"LodgingReservation","reservationNumber":"733"}' } }] }),
|
||||
@@ -695,6 +718,22 @@ describe('AnthropicClient', () => {
|
||||
await expect(new AnthropicClient().extract(baseInput)).rejects.toThrow(/cut off at the 8192-token limit/);
|
||||
});
|
||||
|
||||
it('sends the cap LLM_MAX_OUTPUT_TOKENS sets and names it when the answer hits it (#2677)', async () => {
|
||||
const fetchFn = mockFetch(() =>
|
||||
jsonResponse({
|
||||
stop_reason: 'max_tokens',
|
||||
content: [{ type: 'tool_use', name: 'emit_reservations', input: {} }],
|
||||
}),
|
||||
);
|
||||
vi.stubEnv('LLM_MAX_OUTPUT_TOKENS', '20000');
|
||||
try {
|
||||
await expect(new AnthropicClient().extract(baseInput)).rejects.toThrow(/cut off at the 20000-token limit/);
|
||||
expect(JSON.parse(fetchFn.mock.calls[0][1].body as string).max_tokens).toBe(20000);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when the forced tool was never called (#2375)', async () => {
|
||||
mockFetch(() =>
|
||||
jsonResponse({
|
||||
|
||||
@@ -49,7 +49,7 @@ const FULL = {
|
||||
lat: 48.2101,
|
||||
lng: 16.3657,
|
||||
category: 'coffee_shop',
|
||||
categoryPath: 'eat_and_drink>cafe>coffee_shop',
|
||||
categoryPath: 'food_and_drink>non_alcoholic_beverage_venue>coffee_shop',
|
||||
confidence: 0.94,
|
||||
address: { freeform: 'Herrengasse 14', locality: 'Wien', postcode: '1010', region: null, country: 'AT' },
|
||||
contact: { website: 'https://cafecentral.wien/', phone: '+43 1 5333763', email: null, socials: null },
|
||||
@@ -285,7 +285,7 @@ describe('MapsService.pois answered from the index', () => {
|
||||
radius: 7872,
|
||||
// The caller's budget, per category, the way the Overpass path spends it.
|
||||
limit: 60,
|
||||
category: 'cafe,coffee_shop',
|
||||
category: 'food_and_drink>casual_eatery>cafe,food_and_drink>non_alcoholic_beverage_venue>coffee_shop',
|
||||
});
|
||||
expect(out.clamped).toBe(false);
|
||||
});
|
||||
@@ -296,8 +296,20 @@ describe('MapsService.pois answered from the index', () => {
|
||||
// Overpass — on the one path where that hurts most, because a single search
|
||||
// fans out over sixteen boxes and each races four public mirrors.
|
||||
mockNearby.mockResolvedValue([
|
||||
{ ...FULL, gers: 'f-1', name: 'Aral', category: 'gas_station' },
|
||||
{ ...FULL, gers: 'c-1', name: 'Ionity', category: 'ev_charging_station' },
|
||||
{
|
||||
...FULL,
|
||||
gers: 'f-1',
|
||||
name: 'Aral',
|
||||
category: 'gas_station',
|
||||
categoryPath: 'travel_and_transportation>fueling_station>gas_station',
|
||||
},
|
||||
{
|
||||
...FULL,
|
||||
gers: 'c-1',
|
||||
name: 'Ionity',
|
||||
category: 'ev_charging_station',
|
||||
categoryPath: 'travel_and_transportation>fueling_station>ev_charging_station',
|
||||
},
|
||||
]);
|
||||
const svc = make();
|
||||
const overpass = stubOverpass(svc);
|
||||
@@ -310,7 +322,8 @@ describe('MapsService.pois answered from the index', () => {
|
||||
0,
|
||||
0,
|
||||
expect.objectContaining({
|
||||
category: 'gas_station,fueling_station,ev_charging_station',
|
||||
category:
|
||||
'travel_and_transportation>fueling_station>gas_station,travel_and_transportation>fueling_station>ev_charging_station',
|
||||
// Per category, so a mixed search does not spend the whole allowance on
|
||||
// whichever kind happens to be densest.
|
||||
limit: 120,
|
||||
@@ -333,9 +346,15 @@ describe('MapsService.pois answered from the index', () => {
|
||||
gers: 'r-1',
|
||||
name: 'Trattoria',
|
||||
category: 'italian_restaurant',
|
||||
categoryPath: 'eat_and_drink>restaurant>italian_restaurant',
|
||||
categoryPath: 'food_and_drink>restaurant>european_restaurant>italian_restaurant',
|
||||
},
|
||||
{
|
||||
...FULL,
|
||||
gers: 'f-1',
|
||||
name: 'Aral',
|
||||
category: 'gas_station',
|
||||
categoryPath: 'travel_and_transportation>fueling_station>gas_station',
|
||||
},
|
||||
{ ...FULL, gers: 'f-1', name: 'Aral', category: 'gas_station', categoryPath: 'automotive>gas_station' },
|
||||
]);
|
||||
const svc = make();
|
||||
stubOverpass(svc);
|
||||
@@ -352,22 +371,66 @@ describe('MapsService.pois answered from the index', () => {
|
||||
expect(out.pois[0].poi_type).toBe('italian_restaurant');
|
||||
});
|
||||
|
||||
it('MAPS-POIS-013c: the longest matching term wins, so fast_food does not answer as a cafe', async () => {
|
||||
it('MAPS-POIS-013c: the most specific term wins, so a cafe under casual_eatery answers the cafe pill', async () => {
|
||||
mockNearby.mockResolvedValue([
|
||||
{
|
||||
...FULL,
|
||||
gers: 'q-1',
|
||||
name: 'Imbiss',
|
||||
category: 'fast_food_restaurant',
|
||||
categoryPath: 'eat_and_drink>fast_food>fast_food_restaurant',
|
||||
categoryPath: 'food_and_drink>casual_eatery>fast_food_restaurant',
|
||||
},
|
||||
{ ...FULL, gers: 'q-2', name: 'Kaffeehaus', category: 'cafe', categoryPath: 'food_and_drink>casual_eatery>cafe' },
|
||||
]);
|
||||
const svc = make();
|
||||
stubOverpass(svc);
|
||||
|
||||
const out = await svc.pois('cafe,restaurant', BOX);
|
||||
|
||||
expect(out.pois[0].category).toBe('restaurant');
|
||||
expect(out.pois.map((p) => [p.name, p.category])).toEqual([
|
||||
['Imbiss', 'restaurant'],
|
||||
['Kaffeehaus', 'cafe'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('MAPS-POIS-013d: a place only spelled like the pill is left out, nature brings parks and no car parks (#2663)', async () => {
|
||||
// What the index answered for Vienna: its terms match inside the category
|
||||
// text, so `park` and `garden` brought car parks, furniture stores and a
|
||||
// beer garden, and the parks were four in sixty.
|
||||
const near = (gers: string, name: string, categoryPath: string) => ({
|
||||
...FULL,
|
||||
gers,
|
||||
name,
|
||||
category: categoryPath.slice(categoryPath.lastIndexOf('>') + 1),
|
||||
categoryPath,
|
||||
});
|
||||
mockNearby.mockResolvedValue([
|
||||
near('n-1', 'Stadtpark', 'sports_and_recreation>park'),
|
||||
near('n-2', 'Garage am Ring', 'travel_and_transportation>parking>parking_garage'),
|
||||
near('n-3', 'Möbelhaus', 'shopping>specialty_store>hardware_home_and_garden_store>furniture_store'),
|
||||
near('n-4', 'Schweizerhaus', 'food_and_drink>alcoholic_beverage_venue>beer_garden'),
|
||||
near('n-5', 'Prater', 'arts_and_entertainment>amusement_attraction>amusement_park'),
|
||||
near('n-6', 'Botanischer Garten', 'geographic_entities>built_feature>garden>botanical_garden'),
|
||||
near('n-7', 'Hundezone', 'sports_and_recreation>park>dog_park'),
|
||||
]);
|
||||
const svc = make();
|
||||
const overpass = stubOverpass(svc);
|
||||
|
||||
const out = await svc.pois('nature', BOX);
|
||||
|
||||
expect(overpass).not.toHaveBeenCalled();
|
||||
expect(out.pois.map((p) => p.name)).toEqual(['Stadtpark', 'Botanischer Garten', 'Hundezone']);
|
||||
// Seven came back, so the page was not full; leaving four out does not make it so.
|
||||
expect(out.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('MAPS-POIS-013e: a page holding near misses only is a miss, and Overpass answers', async () => {
|
||||
mockNearby.mockResolvedValue([{ ...FULL, category: 'parking', categoryPath: 'travel_and_transportation>parking' }]);
|
||||
const svc = make();
|
||||
const overpass = stubOverpass(svc);
|
||||
|
||||
expect(await svc.pois('nature', BOX, 'de')).toEqual(OVERPASS_ANSWER);
|
||||
expect(overpass).toHaveBeenCalledWith('nature', BOX, 'de', 60);
|
||||
});
|
||||
|
||||
it('MAPS-POIS-014: a category the index has no terms for sends the whole query to Overpass', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import {
|
||||
DEFAULT_TREK_PLACES_URL,
|
||||
POI_CATEGORY_TO_TREK,
|
||||
indexRowTerm,
|
||||
toPlaceRecord,
|
||||
trekPlacesArea,
|
||||
trekPlacesBaseUrl,
|
||||
@@ -745,4 +746,52 @@ describe('POI_CATEGORY_TO_TREK', () => {
|
||||
expect(terms.length, pill).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('names every category by its full path, so no bare word matches inside another (#2663)', () => {
|
||||
// A bare `park` is inside `parking`, `bar` inside `barber`, `market` inside
|
||||
// `marketing_agency`. Only the top-level `shopping` stands alone, and it is
|
||||
// a whole branch on purpose.
|
||||
for (const [pill, terms] of Object.entries(POI_CATEGORY_TO_TREK)) {
|
||||
for (const term of terms) {
|
||||
if (term === 'shopping' || term === 'lodging') continue;
|
||||
expect(term, pill).toContain('>');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexRowTerm (#2663)', () => {
|
||||
const nature = POI_CATEGORY_TO_TREK.nature;
|
||||
|
||||
it('takes a place whose path is the term or lies under it', () => {
|
||||
expect(indexRowTerm(nature, 'park', 'sports_and_recreation>park')).toBe('sports_and_recreation>park');
|
||||
expect(indexRowTerm(nature, 'national_park', 'sports_and_recreation>park>national_park')).toBe(
|
||||
'sports_and_recreation>park',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves out a place whose path only contains the word', () => {
|
||||
expect(indexRowTerm(nature, 'parking', 'travel_and_transportation>parking')).toBeUndefined();
|
||||
expect(indexRowTerm(nature, 'beer_garden', 'food_and_drink>alcoholic_beverage_venue>beer_garden')).toBeUndefined();
|
||||
expect(
|
||||
indexRowTerm(
|
||||
nature,
|
||||
'furniture_store',
|
||||
'shopping>specialty_store>hardware_home_and_garden_store>furniture_store',
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('judges a place without a path by its leaf', () => {
|
||||
expect(indexRowTerm(nature, 'beach', null)).toBe('geographic_entities>land_feature>beach');
|
||||
expect(indexRowTerm(nature, 'beach_bar', null)).toBeUndefined();
|
||||
expect(indexRowTerm(nature, null, null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('picks the most specific term when several fit', () => {
|
||||
const terms = [...POI_CATEGORY_TO_TREK.nature, ...POI_CATEGORY_TO_TREK.activity];
|
||||
expect(indexRowTerm(terms, 'water_park', 'sports_and_recreation>park>water_park')).toBe(
|
||||
'sports_and_recreation>park>water_park',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,6 +141,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'Trip just ended', // en-fallback
|
||||
'journey.frontpage.suggestionText': 'Turn <strong>{title}</strong> into a Journey', // en-fallback
|
||||
'journey.frontpage.dismiss': 'Dismiss', // en-fallback
|
||||
'journey.frontpage.dismissError': 'تعذر تجاهل هذا الاقتراح',
|
||||
'journey.frontpage.journeyName': 'Journey Name', // en-fallback
|
||||
'journey.frontpage.namePlaceholder': 'e.g. Southeast Asia 2026', // en-fallback
|
||||
'journey.frontpage.selectTrips': 'Select Trips', // en-fallback
|
||||
|
||||
@@ -111,6 +111,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.deleteCat.few': 'هل أنت متأكد أنك تريد حذف القائمة "{name}" التي تحتوي على {count} عناصر؟',
|
||||
'packing.confirm.deleteCat.many': 'هل أنت متأكد أنك تريد حذف القائمة "{name}" التي تحتوي على {count} عنصرًا؟',
|
||||
'packing.defaultCategory': 'أخرى',
|
||||
'packing.dragCategory': 'اسحب لإعادة ترتيب الفئة',
|
||||
'packing.toast.saveError': 'فشل الحفظ',
|
||||
'packing.toast.deleteError': 'فشل الحذف',
|
||||
'packing.toast.renameError': 'فشلت إعادة التسمية',
|
||||
|
||||
@@ -294,7 +294,7 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': 'تفعيل 2FA',
|
||||
'settings.mfa.cancelSetup': 'إلغاء',
|
||||
'settings.mfa.disableTitle': 'تعطيل 2FA',
|
||||
'settings.mfa.disableHint': 'أدخل كلمة مرور حسابك ورمزًا حاليًا من المصادقة.',
|
||||
'settings.mfa.disableHint': 'أدخل كلمة مرور حسابك ورمزًا حاليًا من المصادقة أو رمزًا احتياطيًا غير مستخدم.',
|
||||
'settings.mfa.disable': 'تعطيل 2FA',
|
||||
'settings.mfa.toastEnabled': 'تم تفعيل المصادقة الثنائية',
|
||||
'settings.mfa.toastDisabled': 'تم تعطيل المصادقة الثنائية',
|
||||
|
||||
@@ -78,6 +78,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'Səyahət indicə başa çatdı',
|
||||
'journey.frontpage.suggestionText': '<strong>{title}</strong> səyahətini gündəliyə çevirin',
|
||||
'journey.frontpage.dismiss': 'Bağla',
|
||||
'journey.frontpage.dismissError': 'Bu təklifi gizlətmək mümkün olmadı',
|
||||
'journey.frontpage.journeyName': 'Səyahət gündəliyinin adı',
|
||||
'journey.frontpage.namePlaceholder': 'məs. Cənub-Şərqi Asiya 2026',
|
||||
'journey.frontpage.selectTrips': 'Səyahətləri seç',
|
||||
|
||||
@@ -117,6 +117,7 @@ const packing: TranslationStrings = {
|
||||
'“{name}” siyahısını daxilindəki {count} elementlə birlikdə silmək istədiyinizə əminsiniz?',
|
||||
|
||||
'packing.defaultCategory': 'Digər',
|
||||
'packing.dragCategory': 'Kateqoriyanı yerini dəyişmək üçün sürüşdürün',
|
||||
'packing.toast.saveError': 'Yadda saxlamaq mümkün olmadı',
|
||||
'packing.toast.deleteError': 'Silmək mümkün olmadı',
|
||||
'packing.toast.renameError': 'Adını dəyişmək mümkün olmadı',
|
||||
|
||||
@@ -390,7 +390,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': '2FA-nı aktivləşdir',
|
||||
'settings.mfa.cancelSetup': 'Ləğv et',
|
||||
'settings.mfa.disableTitle': '2FA-nı deaktiv et',
|
||||
'settings.mfa.disableHint': 'Hesab parolunuzu və autentifikatorunuzdakı cari kodu daxil edin.',
|
||||
'settings.mfa.disableHint':
|
||||
'Hesab parolunuzu və autentifikatorunuzdakı cari kodu və ya istifadə olunmamış ehtiyat kodu daxil edin.',
|
||||
'settings.mfa.disable': '2FA-nı deaktiv et',
|
||||
'settings.mfa.toastEnabled': 'İki mərhələli autentifikasiya aktivləşdirildi',
|
||||
'settings.mfa.toastDisabled': 'İki mərhələli autentifikasiya deaktiv edildi',
|
||||
|
||||
@@ -70,6 +70,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'A viagem acabou de terminar',
|
||||
'journey.frontpage.suggestionText': 'Transforme <strong>{title}</strong> em uma jornada',
|
||||
'journey.frontpage.dismiss': 'Dispensar',
|
||||
'journey.frontpage.dismissError': 'Não foi possível dispensar esta sugestão',
|
||||
'journey.frontpage.journeyName': 'Nome da jornada',
|
||||
'journey.frontpage.namePlaceholder': 'ex. Sudeste Asiático 2026',
|
||||
'journey.frontpage.selectTrips': 'Selecionar viagens',
|
||||
|
||||
@@ -82,6 +82,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.deleteCat': 'Tem certeza de que deseja excluir a lista "{name}" com {count} itens?',
|
||||
'packing.confirm.deleteCat.one': 'Tem certeza de que deseja excluir a lista "{name}" com {count} item?',
|
||||
'packing.defaultCategory': 'Outros',
|
||||
'packing.dragCategory': 'Arraste para reordenar a categoria',
|
||||
'packing.toast.saveError': 'Falha ao salvar',
|
||||
'packing.toast.deleteError': 'Falha ao excluir',
|
||||
'packing.toast.renameError': 'Falha ao renomear',
|
||||
|
||||
@@ -203,7 +203,7 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': 'Ativar 2FA',
|
||||
'settings.mfa.cancelSetup': 'Cancelar',
|
||||
'settings.mfa.disableTitle': 'Desativar 2FA',
|
||||
'settings.mfa.disableHint': 'Digite sua senha e um código atual do autenticador.',
|
||||
'settings.mfa.disableHint': 'Digite sua senha e um código atual do autenticador ou um código de backup não usado.',
|
||||
'settings.mfa.disable': 'Desativar 2FA',
|
||||
'settings.mfa.toastEnabled': 'Autenticação em duas etapas ativada',
|
||||
'settings.mfa.toastDisabled': 'Autenticação em duas etapas desativada',
|
||||
|
||||
@@ -70,6 +70,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'El viatge acaba de terminar',
|
||||
'journey.frontpage.suggestionText': 'Converteix <strong>{title}</strong> en una travesia',
|
||||
'journey.frontpage.dismiss': 'Descarta',
|
||||
'journey.frontpage.dismissError': "No s'ha pogut descartar aquest suggeriment",
|
||||
'journey.frontpage.journeyName': 'Nom de la travesia',
|
||||
'journey.frontpage.namePlaceholder': 'p. ex. Sud-est Asiàtic 2026',
|
||||
'journey.frontpage.selectTrips': 'Selecciona viatges',
|
||||
|
||||
@@ -70,6 +70,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.deleteCat': 'Segur que vols eliminar la categoria "{name}" amb {count} elements?',
|
||||
'packing.confirm.deleteCat.one': 'Segur que vols eliminar la categoria "{name}" amb {count} element?',
|
||||
'packing.defaultCategory': 'Altres',
|
||||
'packing.dragCategory': 'Arrossega per reordenar la categoria',
|
||||
'packing.toast.saveError': "No s'ha pogut desar",
|
||||
'packing.toast.deleteError': "No s'ha pogut eliminar",
|
||||
'packing.toast.renameError': "No s'ha pogut canviar el nom",
|
||||
|
||||
@@ -253,7 +253,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': 'Activa el 2FA',
|
||||
'settings.mfa.cancelSetup': 'Cancel·la',
|
||||
'settings.mfa.disableTitle': 'Desactiva el 2FA',
|
||||
'settings.mfa.disableHint': 'Introdueix la teva contrasenya i un codi actual del teu autenticador.',
|
||||
'settings.mfa.disableHint':
|
||||
'Introdueix la teva contrasenya i un codi actual del teu autenticador o un codi de seguretat no utilitzat.',
|
||||
'settings.mfa.disable': 'Desactiva el 2FA',
|
||||
'settings.mfa.toastEnabled': 'Autenticació de dos factors activada',
|
||||
'settings.mfa.toastDisabled': 'Autenticació de dos factors desactivada',
|
||||
|
||||
@@ -71,6 +71,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'Cesta právě skončila',
|
||||
'journey.frontpage.suggestionText': 'Proměňte <strong>{title}</strong> v cestovní deník',
|
||||
'journey.frontpage.dismiss': 'Zavřít',
|
||||
'journey.frontpage.dismissError': 'Tento návrh se nepodařilo skrýt',
|
||||
'journey.frontpage.journeyName': 'Název cestovního deníku',
|
||||
'journey.frontpage.namePlaceholder': 'např. Jihovýchodní Asie 2026',
|
||||
'journey.frontpage.selectTrips': 'Vybrat cesty',
|
||||
|
||||
@@ -90,6 +90,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.deleteCat.one': 'Opravdu chcete smazat seznam „{name}“ s {count} položkou?',
|
||||
'packing.confirm.deleteCat.few': 'Opravdu chcete smazat seznam „{name}“ s {count} položkami?',
|
||||
'packing.defaultCategory': 'Ostatní',
|
||||
'packing.dragCategory': 'Přetažením změníte pořadí kategorie',
|
||||
'packing.toast.saveError': 'Uložení se nezdařilo',
|
||||
'packing.toast.deleteError': 'Smazání se nezdařilo',
|
||||
'packing.toast.renameError': 'Přejmenování se nezdařilo',
|
||||
|
||||
@@ -284,7 +284,7 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': 'Zapnout 2FA',
|
||||
'settings.mfa.cancelSetup': 'Zrušit',
|
||||
'settings.mfa.disableTitle': 'Vypnout 2FA',
|
||||
'settings.mfa.disableHint': 'Zadejte své heslo k účtu a aktuální kód z aplikace.',
|
||||
'settings.mfa.disableHint': 'Zadejte své heslo k účtu a aktuální kód z aplikace nebo nepoužitý záložní kód.',
|
||||
'settings.mfa.disable': 'Vypnout 2FA',
|
||||
'settings.mfa.toastEnabled': 'Dvoufaktorové ověření bylo zapnuto',
|
||||
'settings.mfa.toastDisabled': 'Dvoufaktorové ověření bylo vypnuto',
|
||||
|
||||
@@ -70,6 +70,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'Trip gerade beendet',
|
||||
'journey.frontpage.suggestionText': 'Verwandle <strong>{title}</strong> in eine Journey',
|
||||
'journey.frontpage.dismiss': 'Schließen',
|
||||
'journey.frontpage.dismissError': 'Dieser Vorschlag konnte nicht ausgeblendet werden',
|
||||
'journey.frontpage.journeyName': 'Journey-Name',
|
||||
'journey.frontpage.namePlaceholder': 'z.B. Südostasien 2026',
|
||||
'journey.frontpage.selectTrips': 'Trips auswählen',
|
||||
|
||||
@@ -102,6 +102,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.deleteCat': 'Möchtest du die Liste „{name}“ mit {count} Einträgen wirklich löschen?',
|
||||
'packing.confirm.deleteCat.one': 'Möchtest du die Liste „{name}“ mit {count} Eintrag wirklich löschen?',
|
||||
'packing.defaultCategory': 'Sonstiges',
|
||||
'packing.dragCategory': 'Ziehen, um die Kategorie umzusortieren',
|
||||
'packing.toast.saveError': 'Fehler beim Speichern',
|
||||
'packing.toast.deleteError': 'Fehler beim Löschen',
|
||||
'packing.toast.renameError': 'Fehler beim Umbenennen',
|
||||
|
||||
@@ -292,7 +292,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': '2FA aktivieren',
|
||||
'settings.mfa.cancelSetup': 'Abbrechen',
|
||||
'settings.mfa.disableTitle': '2FA deaktivieren',
|
||||
'settings.mfa.disableHint': 'Passwort und einen aktuellen Code aus der Authenticator-App eingeben.',
|
||||
'settings.mfa.disableHint':
|
||||
'Passwort und einen aktuellen Code aus der Authenticator-App oder einen unbenutzten Backup-Code eingeben.',
|
||||
'settings.mfa.disable': '2FA deaktivieren',
|
||||
'settings.mfa.toastEnabled': 'Zwei-Faktor-Authentifizierung aktiviert',
|
||||
'settings.mfa.toastDisabled': 'Zwei-Faktor-Authentifizierung deaktiviert',
|
||||
|
||||
@@ -70,6 +70,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'Trip just ended',
|
||||
'journey.frontpage.suggestionText': 'Turn <strong>{title}</strong> into a Journey',
|
||||
'journey.frontpage.dismiss': 'Dismiss',
|
||||
'journey.frontpage.dismissError': 'Could not dismiss this suggestion',
|
||||
'journey.frontpage.journeyName': 'Journey Name',
|
||||
'journey.frontpage.namePlaceholder': 'e.g. Southeast Asia 2026',
|
||||
'journey.frontpage.selectTrips': 'Select Trips',
|
||||
|
||||
@@ -101,6 +101,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.deleteCat': 'Are you sure you want to delete the list "{name}" with {count} items?',
|
||||
'packing.confirm.deleteCat.one': 'Are you sure you want to delete the list "{name}" with {count} item?',
|
||||
'packing.defaultCategory': 'Other',
|
||||
'packing.dragCategory': 'Drag to reorder the category',
|
||||
'packing.toast.saveError': 'Failed to save',
|
||||
'packing.toast.deleteError': 'Failed to delete',
|
||||
'packing.toast.renameError': 'Failed to rename',
|
||||
|
||||
@@ -350,7 +350,7 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': 'Enable 2FA',
|
||||
'settings.mfa.cancelSetup': 'Cancel',
|
||||
'settings.mfa.disableTitle': 'Disable 2FA',
|
||||
'settings.mfa.disableHint': 'Enter your account password and a current code from your authenticator.',
|
||||
'settings.mfa.disableHint': 'Enter your account password and a current authenticator code or an unused backup code.',
|
||||
'settings.mfa.disable': 'Disable 2FA',
|
||||
'settings.mfa.toastEnabled': 'Two-factor authentication enabled',
|
||||
'settings.mfa.toastDisabled': 'Two-factor authentication disabled',
|
||||
|
||||
@@ -70,6 +70,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'El viaje acaba de terminar',
|
||||
'journey.frontpage.suggestionText': 'Convierte <strong>{title}</strong> en una travesía',
|
||||
'journey.frontpage.dismiss': 'Descartar',
|
||||
'journey.frontpage.dismissError': 'No se pudo descartar esta sugerencia',
|
||||
'journey.frontpage.journeyName': 'Nombre de la travesía',
|
||||
'journey.frontpage.namePlaceholder': 'p. ej. Sudeste Asiático 2026',
|
||||
'journey.frontpage.selectTrips': 'Seleccionar viajes',
|
||||
|
||||
@@ -83,6 +83,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.deleteCat': '¿Seguro que quieres eliminar la lista "{name}" con {count} elementos?',
|
||||
'packing.confirm.deleteCat.one': '¿Seguro que quieres eliminar la lista "{name}" con {count} elemento?',
|
||||
'packing.defaultCategory': 'Otros',
|
||||
'packing.dragCategory': 'Arrastra para reordenar la categoría',
|
||||
'packing.toast.saveError': 'No se pudo guardar',
|
||||
'packing.toast.deleteError': 'No se pudo eliminar',
|
||||
'packing.toast.renameError': 'No se pudo renombrar',
|
||||
|
||||
@@ -270,7 +270,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': 'Activar 2FA',
|
||||
'settings.mfa.cancelSetup': 'Cancelar',
|
||||
'settings.mfa.disableTitle': 'Desactivar 2FA',
|
||||
'settings.mfa.disableHint': 'Introduce tu contraseña y un código actual de tu autenticador.',
|
||||
'settings.mfa.disableHint':
|
||||
'Introduce tu contraseña y un código actual de tu autenticador o un código de respaldo sin usar.',
|
||||
'settings.mfa.disable': 'Desactivar 2FA',
|
||||
'settings.mfa.toastEnabled': 'Autenticación de dos factores activada',
|
||||
'settings.mfa.toastDisabled': 'Autenticación de dos factores desactivada',
|
||||
|
||||
@@ -70,6 +70,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'Reis just lõppes',
|
||||
'journey.frontpage.suggestionText': 'Tee reisist <strong>{title}</strong> reisilugu',
|
||||
'journey.frontpage.dismiss': 'Jäta kõrvale',
|
||||
'journey.frontpage.dismissError': 'Soovitust ei saanud peita',
|
||||
'journey.frontpage.journeyName': 'Reisiloo nimi',
|
||||
'journey.frontpage.namePlaceholder': 'Nt Kagu-Aasia 2026',
|
||||
'journey.frontpage.selectTrips': 'Vali reisid',
|
||||
|
||||
@@ -101,6 +101,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.deleteCat': 'Kas soovid kindlasti kustutada nimekirja "{name}" koos {count} esemega?',
|
||||
'packing.confirm.deleteCat.one': 'Kas soovid kindlasti kustutada nimekirja "{name}" koos {count} esemega?',
|
||||
'packing.defaultCategory': 'Muu',
|
||||
'packing.dragCategory': 'Lohista kategooria järjekorra muutmiseks',
|
||||
'packing.toast.saveError': 'Salvestamine ebaõnnestus',
|
||||
'packing.toast.deleteError': 'Kustutamine ebaõnnestus',
|
||||
'packing.toast.renameError': 'Ümbernimetamine ebaõnnestus',
|
||||
|
||||
@@ -355,7 +355,7 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': 'Luba 2FA',
|
||||
'settings.mfa.cancelSetup': 'Tühista',
|
||||
'settings.mfa.disableTitle': 'Keela 2FA',
|
||||
'settings.mfa.disableHint': 'Sisesta oma konto parool ja autentimisrakenduse kehtiv kood.',
|
||||
'settings.mfa.disableHint': 'Sisesta oma konto parool ja autentimisrakenduse kehtiv kood või kasutamata varukood.',
|
||||
'settings.mfa.disable': 'Keela 2FA',
|
||||
'settings.mfa.toastEnabled': 'Kaheastmeline autentimine lubatud',
|
||||
'settings.mfa.toastDisabled': 'Kaheastmeline autentimine keelatud',
|
||||
|
||||
@@ -70,6 +70,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'Voyage terminé récemment',
|
||||
'journey.frontpage.suggestionText': 'Transformez <strong>{title}</strong> en journal de voyage',
|
||||
'journey.frontpage.dismiss': 'Ignorer',
|
||||
'journey.frontpage.dismissError': 'Impossible d’ignorer cette suggestion',
|
||||
'journey.frontpage.journeyName': 'Nom du journal',
|
||||
'journey.frontpage.namePlaceholder': 'ex. Asie du Sud-Est 2026',
|
||||
'journey.frontpage.selectTrips': 'Sélectionner des voyages',
|
||||
|
||||
@@ -83,6 +83,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.deleteCat': 'Voulez-vous vraiment supprimer la liste « {name} » contenant {count} éléments ?',
|
||||
'packing.confirm.deleteCat.one': 'Voulez-vous vraiment supprimer la liste « {name} » contenant {count} élément ?',
|
||||
'packing.defaultCategory': 'Autre',
|
||||
'packing.dragCategory': 'Faites glisser pour réordonner la catégorie',
|
||||
'packing.toast.saveError': "Échec de l'enregistrement",
|
||||
'packing.toast.deleteError': 'Échec de la suppression',
|
||||
'packing.toast.renameError': 'Échec du renommage',
|
||||
|
||||
@@ -284,7 +284,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': 'Activer 2FA',
|
||||
'settings.mfa.cancelSetup': 'Annuler',
|
||||
'settings.mfa.disableTitle': 'Désactiver 2FA',
|
||||
'settings.mfa.disableHint': 'Entrez votre mot de passe et un code actuel de votre authentificateur.',
|
||||
'settings.mfa.disableHint':
|
||||
'Entrez votre mot de passe et un code actuel de votre authentificateur ou un code de secours inutilisé.',
|
||||
'settings.mfa.disable': 'Désactiver 2FA',
|
||||
'settings.mfa.toastEnabled': 'Authentification à deux facteurs activée',
|
||||
'settings.mfa.toastDisabled': 'Authentification à deux facteurs désactivée',
|
||||
|
||||
@@ -70,6 +70,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'Το ταξίδι μόλις τελείωσε',
|
||||
'journey.frontpage.suggestionText': 'Μετατρέψτε το <strong>{title}</strong> σε Ταξίδι',
|
||||
'journey.frontpage.dismiss': 'Απόρριψη',
|
||||
'journey.frontpage.dismissError': 'Δεν ήταν δυνατή η απόκρυψη αυτής της πρότασης',
|
||||
'journey.frontpage.journeyName': 'Όνομα Ταξιδιού',
|
||||
'journey.frontpage.namePlaceholder': 'π.χ. Νοτιοανατολική Ασία 2026',
|
||||
'journey.frontpage.selectTrips': 'Επιλογή Ταξιδιών',
|
||||
|
||||
@@ -84,6 +84,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.deleteCat': 'Είστε βέβαιοι ότι θέλετε να διαγράψετε τη λίστα "{name}" με {count} στοιχεία;',
|
||||
'packing.confirm.deleteCat.one': 'Είστε βέβαιοι ότι θέλετε να διαγράψετε τη λίστα "{name}" με {count} στοιχείο;',
|
||||
'packing.defaultCategory': 'Άλλα',
|
||||
'packing.dragCategory': 'Σύρετε για να αλλάξετε τη σειρά της κατηγορίας',
|
||||
'packing.toast.saveError': 'Αποτυχία αποθήκευσης',
|
||||
'packing.toast.deleteError': 'Αποτυχία διαγραφής',
|
||||
'packing.toast.renameError': 'Αποτυχία μετονομασίας',
|
||||
|
||||
@@ -354,7 +354,7 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.cancelSetup': 'Ακύρωση',
|
||||
'settings.mfa.disableTitle': 'Απενεργοποίηση 2FA',
|
||||
'settings.mfa.disableHint':
|
||||
'Εισάγετε τον κωδικό του λογαριασμού σας και έναν τρέχοντα κωδικό από τον authenticator σας.',
|
||||
'Εισαγάγετε τον κωδικό πρόσβασης του λογαριασμού σας και έναν τρέχοντα κωδικό από την εφαρμογή ελέγχου ταυτότητας ή έναν αχρησιμοποίητο εφεδρικό κωδικό.',
|
||||
'settings.mfa.disable': 'Απενεργοποίηση 2FA',
|
||||
'settings.mfa.toastEnabled': 'Ο έλεγχος ταυτότητας δύο παραγόντων ενεργοποιήθηκε',
|
||||
'settings.mfa.toastDisabled': 'Ο έλεγχος ταυτότητας δύο παραγόντων απενεργοποιήθηκε',
|
||||
|
||||
@@ -70,6 +70,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'Az út épp véget ért',
|
||||
'journey.frontpage.suggestionText': 'Alakítsd a(z) <strong>{title}</strong> útinaplóvá',
|
||||
'journey.frontpage.dismiss': 'Elvetés',
|
||||
'journey.frontpage.dismissError': 'Nem sikerült elrejteni ezt a javaslatot',
|
||||
'journey.frontpage.journeyName': 'Útinapló neve',
|
||||
'journey.frontpage.namePlaceholder': 'pl. Délkelet-Ázsia 2026',
|
||||
'journey.frontpage.selectTrips': 'Utak kiválasztása',
|
||||
|
||||
@@ -85,6 +85,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.deleteCat.one':
|
||||
'Biztosan törölni szeretnéd a(z) „{name}” listát a benne lévő {count} elemmel együtt?',
|
||||
'packing.defaultCategory': 'Egyéb',
|
||||
'packing.dragCategory': 'Húzd a kategória átrendezéséhez',
|
||||
'packing.toast.saveError': 'Nem sikerült menteni',
|
||||
'packing.toast.deleteError': 'Nem sikerült törölni',
|
||||
'packing.toast.renameError': 'Nem sikerült átnevezni',
|
||||
|
||||
@@ -289,7 +289,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': '2FA engedélyezése',
|
||||
'settings.mfa.cancelSetup': 'Mégse',
|
||||
'settings.mfa.disableTitle': '2FA kikapcsolása',
|
||||
'settings.mfa.disableHint': 'Add meg a fiókod jelszavát és a hitelesítő alkalmazás aktuális kódját.',
|
||||
'settings.mfa.disableHint':
|
||||
'Add meg a fiókod jelszavát és a hitelesítő alkalmazás aktuális kódját vagy egy fel nem használt tartalékkódot.',
|
||||
'settings.mfa.disable': '2FA kikapcsolása',
|
||||
'settings.mfa.toastEnabled': 'Kétfaktoros hitelesítés engedélyezve',
|
||||
'settings.mfa.toastDisabled': 'Kétfaktoros hitelesítés kikapcsolva',
|
||||
|
||||
@@ -69,6 +69,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'Perjalanan baru saja selesai',
|
||||
'journey.frontpage.suggestionText': 'Ubah <strong>{title}</strong> menjadi Journey',
|
||||
'journey.frontpage.dismiss': 'Tutup',
|
||||
'journey.frontpage.dismissError': 'Saran ini tidak dapat disembunyikan',
|
||||
'journey.frontpage.journeyName': 'Nama Journey',
|
||||
'journey.frontpage.namePlaceholder': 'mis. Asia Tenggara 2026',
|
||||
'journey.frontpage.selectTrips': 'Pilih Perjalanan',
|
||||
|
||||
@@ -76,6 +76,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.clearChecked': 'Yakin ingin menghapus {count} item yang dicentang?',
|
||||
'packing.confirm.deleteCat': 'Yakin ingin menghapus daftar "{name}" dengan {count} item?',
|
||||
'packing.defaultCategory': 'Lainnya',
|
||||
'packing.dragCategory': 'Seret untuk mengurutkan ulang kategori',
|
||||
'packing.toast.saveError': 'Gagal menyimpan',
|
||||
'packing.toast.deleteError': 'Gagal menghapus',
|
||||
'packing.toast.renameError': 'Gagal mengganti nama',
|
||||
|
||||
@@ -340,7 +340,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': 'Aktifkan 2FA',
|
||||
'settings.mfa.cancelSetup': 'Batal',
|
||||
'settings.mfa.disableTitle': 'Nonaktifkan 2FA',
|
||||
'settings.mfa.disableHint': 'Masukkan kata sandi akun dan kode terkini dari aplikasi autentikatormu.',
|
||||
'settings.mfa.disableHint':
|
||||
'Masukkan kata sandi akun dan kode terkini dari aplikasi autentikatormu atau kode cadangan yang belum dipakai.',
|
||||
'settings.mfa.disable': 'Nonaktifkan 2FA',
|
||||
'settings.mfa.toastEnabled': 'Autentikasi dua faktor diaktifkan',
|
||||
'settings.mfa.toastDisabled': 'Autentikasi dua faktor dinonaktifkan',
|
||||
|
||||
@@ -70,6 +70,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'Viaggio appena terminato',
|
||||
'journey.frontpage.suggestionText': 'Trasforma <strong>{title}</strong> in un diario di viaggio',
|
||||
'journey.frontpage.dismiss': 'Ignora',
|
||||
'journey.frontpage.dismissError': 'Impossibile ignorare questo suggerimento',
|
||||
'journey.frontpage.journeyName': 'Nome del diario',
|
||||
'journey.frontpage.namePlaceholder': 'es. Sud-est asiatico 2026',
|
||||
'journey.frontpage.selectTrips': 'Seleziona viaggi',
|
||||
|
||||
@@ -83,6 +83,7 @@ const packing: TranslationStrings = {
|
||||
'packing.confirm.deleteCat': 'Vuoi davvero eliminare la lista "{name}" con {count} elementi?',
|
||||
'packing.confirm.deleteCat.one': 'Vuoi davvero eliminare la lista "{name}" con {count} elemento?',
|
||||
'packing.defaultCategory': 'Altro',
|
||||
'packing.dragCategory': 'Trascina per riordinare la categoria',
|
||||
'packing.toast.saveError': 'Impossibile salvare',
|
||||
'packing.toast.deleteError': 'Impossibile eliminare',
|
||||
'packing.toast.renameError': 'Impossibile rinominare',
|
||||
|
||||
@@ -288,7 +288,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.enable': 'Abilita 2FA',
|
||||
'settings.mfa.cancelSetup': 'Annulla',
|
||||
'settings.mfa.disableTitle': 'Disabilita 2FA',
|
||||
'settings.mfa.disableHint': 'Inserisci la password del tuo account e un codice attuale dal tuo authenticator.',
|
||||
'settings.mfa.disableHint':
|
||||
'Inserisci la password del tuo account e un codice attuale dal tuo authenticator o un codice di backup non utilizzato.',
|
||||
'settings.mfa.disable': 'Disabilita 2FA',
|
||||
'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata',
|
||||
'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user