Compare commits

...

22 Commits

Author SHA1 Message Date
Maurice e725d4f0a9 test(packing): sorted A to Z only the category grip stays draggable 2026-10-18 14:43:55 +02:00
Maurice 36f1b5b7d9 refactor(journey): read the stored dismissed suggestions as the list they are written as
The reader also took {id, dismissedAt} records, a shape nothing writes.
2026-10-18 14:07:25 +02:00
Luffy acce063dbf fix: fix journey test fail 2026-10-18 14:04:23 +02:00
Luffy d229613e0e fix(journey): add localized error messages for dismissing suggestions 2026-10-18 14:04:22 +02:00
Luffy 5622113348 fix(journey): persist dismissed suggestions in local storage 2026-10-18 14:04:22 +02:00
Maurice cd2ecf897b test(mcp): search_pois asks the index by Overture path 2026-10-18 13:58:30 +02:00
Maurice f5af9d1259 chore(deps-dev): bump source-map-js to 1.2.2 in /plugin-sdk
Replaces Dependabot's #2680, whose branch sat 983 commits behind dev.
2026-10-18 13:21:54 +02:00
Maurice cecfdf2dcc fix(packing): tell a dragged category by its data type, not by a state flag
A flag set on dragstart could still be unrendered when a quick drag reached
the next card, and the drop was lost. The category now travels in the drag
under its own type, read from the first dragover on and at the drop.
2026-10-18 13:21:54 +02:00
Maurice 23b28a1f3f feat(packing): drag a category by its head to move it among the others
Categories showed in the order they were created, which a list kept per day
cannot undo once the days swap. A grip in each category's head band moves
the whole category onto another's place on desktop. A category has no
position of its own, it shows where its first item is, so the move reorders
the items as one block through the item reorder the list already has: no
new column, and the order reaches everyone the way a dragged item does. The
other view's categories keep their order.
2026-10-18 13:21:54 +02:00
Maurice f7f6b83d33 refactor(journey,docsync): keep both services inside their size baseline
The time placement of a journey entry moves whole into journey-entry-order,
and a moved entry only gets a sort_order when it actually moves, so a patch
never carries an undefined one. The Synology login comment shrinks to a line.
2026-10-18 13:21:53 +02:00
Maurice 097c1da0af fix(mobile): a transport card shows the notes of its booking
On the phone the Transports tab never rendered a booking's notes, so what
someone typed into a flight was only to be found by opening its sheet,
while the booking cards next to it showed theirs. Both cards now take the
notes from one part, sized on the caption tier so the text size setting
reaches them.
2026-10-18 13:21:53 +02:00
Maurice a74a2cf9b5 fix(map): the day route waits out a busy router instead of keeping straight lines
The public OSRM hosts answer 429 above about one request a second. The trip
overview and the road trip waited that out and asked again; the day route
took the first refusal as final and drew straight lines between the stops
until the next edit, which is what a day with a change of mode or a quick
switch between days ran into. Both the day route and the overview now go
through one retry helper: a rate limit is waited out, as long as the host
asks when it says, and a refusal the host meant still ends at once.
2026-10-18 13:21:52 +02:00
Maurice dc1ff06573 fix(docsync): Synology login sends plain credentials, a refused test shows its reason
The login went to auth.cgi with account and password JSON-quoted, the way
the FileStation calls on entry.cgi take their values. entry.cgi reads JSON
where it can; auth.cgi takes a value as it comes, so DSM looked for a user
named "trek" with the quotes and refused every account. Both go plain now.

The test verdict sat truncated next to the dialog buttons ("The credentials
were re..."), with nothing to read the rest by. It wraps to two lines and
shows the store's own reason on hover; the phone sheet prints it below.

Closes #2666
2026-10-18 13:21:52 +02:00
Maurice fc631d36b6 fix(maps): category pills ask the index by Overture path, near misses are left out
The index matches a term anywhere inside a place's category text, so the
bare names behind the pills caught whatever was spelled like them: "park"
brought car parks and an amusement park, "garden" furniture stores and beer
gardens, "bar" barbers, "pub" publishers and public squares, "market"
marketing agencies. In Vienna four of sixty "Nature & parks" pins were
parks. Every pill now names its Overture categories by full path, a place
counts only when its path is that path or lies under it, and a page of
near misses only falls through to Overpass. Terms Overture no longer has
(nightclub, nature_preserve, shopping_center ...) got their current names.

Closes #2663
2026-10-18 13:21:52 +02:00
Maurice 6463060f67 chore(deps): bump concurrently to 10.0.6 and shell-quote to 1.12.0
Replaces Dependabot's #2658, closed because its security rebase kept
bouncing between main and dev. Only the two packages move in the lockfile.
2026-10-18 13:21:51 +02:00
Maurice 593f839592 fix(journey): a timed entry takes its place in the day by its time
A new entry always went to the end of its day, so writing up 1 am after
2 am left the night out of order, and changing an entry's time never moved
it. A timed entry now lands before the first entry of its day that starts
later, on create and when its time or day changes; untimed entries still
go last, and the hand-made order of the others stays as it was. A save
that leaves time and day untouched keeps an entry where it was dragged.

Closes #2679
2026-10-18 13:21:51 +02:00
Maurice a9c5cf6d9a fix(share): a stop's stay comes from its time window, not the one-hour place default
The public page showed places.duration_minutes under every stop, and that is
60 on every place nobody set a road-trip stay for, so a shared plan read "1 h"
everywhere. The stay is now the stop's start to end time, per visit, past
midnight included, and a stop without an end shows none.

Closes #2676
2026-10-18 13:21:51 +02:00
Maurice f901982903 fix(llm-parse): LLM_MAX_OUTPUT_TOKENS sets the answer cap, a cut-off answer says so
The OpenAI-compatible client always asked for 4096 tokens, and a document
whose bookings needed more came back as "no reservations" with nothing that
pointed at the cap. LLM_MAX_OUTPUT_TOKENS now sets the cap for every
provider; unset keeps 4096 and 8192 so a small local model's context still
fits. A finish_reason of "length" is reported like Anthropic's max_tokens
stop, naming the limit and the variable that raises it.

Closes #2677
2026-10-18 13:21:50 +02:00
Maurice 8f64431ff1 refactor(auth): one helper finds a backup code for login, reset and MFA disable
Keeps auth.service.ts inside its size baseline: the recovery-code check the
disable path gained is the lookup the password reset already spelled out.
2026-10-18 12:35:37 +02:00
Maurice c378722d63 test(auth): read the disabled MFA state through the ORM, formatted 2026-10-18 12:11:41 +02:00
Maurice bfec874d04 fix(i18n): mention backup codes in the disable-2FA hint for every locale 2026-10-18 11:47:11 +02:00
Jens Kowalsky a36019b7e2 fix(auth): allow backup codes for TOTP recovery 2026-10-18 11:47:10 +02:00
141 changed files with 1309 additions and 198 deletions
+1 -1
View File
@@ -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;
});
});
+48
View File
@@ -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);
}
}
}
+12 -32
View File
@@ -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`}
+3 -2
View File
@@ -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>
+77 -1
View File
@@ -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();
+2 -2
View File
@@ -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')}
+2 -4
View File
@@ -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);
+20 -2
View File
@@ -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)', () => {
+59 -3
View File
@@ -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.
*
+1
View File
@@ -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)
+8 -8
View File
@@ -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
View File
@@ -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"
},
+3 -3
View File
@@ -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": {
+1
View File
@@ -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.
+1 -1
View File
@@ -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
+7
View File
@@ -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).
+2
View File
@@ -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>()
+5
View File
@@ -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));
}
+7 -7
View File
@@ -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);
}
+24 -16
View File
@@ -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>();
/**
+3 -2
View File
@@ -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);
}
+56 -17
View File
@@ -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;
}
+46
View File
@@ -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);
});
});
// ─────────────────────────────────────────────────────────────────────────────
+53
View File
@@ -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({
+73 -10
View File
@@ -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',
);
});
});
+1
View File
@@ -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
+1
View File
@@ -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': 'فشلت إعادة التسمية',
+1 -1
View File
@@ -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': 'تم تعطيل المصادقة الثنائية',
+1
View File
@@ -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ç',
+1
View File
@@ -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ı',
+2 -1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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",
+2 -1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+2 -1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+2 -1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1 -1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+2 -1
View File
@@ -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',
+1
View File
@@ -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': 'Επιλογή Ταξιδιών',
+1
View File
@@ -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': 'Αποτυχία μετονομασίας',
+1 -1
View File
@@ -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': 'Ο έλεγχος ταυτότητας δύο παραγόντων απενεργοποιήθηκε',
+1
View File
@@ -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',
+1
View File
@@ -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',
+2 -1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+2 -1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+2 -1
View File
@@ -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