feat(tours): let a background check hint at changes of your own wanderer trails

Settings, Integrations, wanderer gains an optional background check of the
trails you imported, off until you switch it on. While on, TREK reads the
short record of each such trail at most once a day (never its GPX file) and
leaves a hint when the trail may have moved on: "may have changed" when only
wanderer's change time moved, "changed" when its name or description proves
it, and "not checkable" when the trail is gone, no longer yours or carries no
change time. Nothing is ever said to be unchanged. A new hint arrives as a
notification that opens the tour (a new ?place= link in the planner); the
tour's provenance shows it with the source check right beside it, and the
card lists all open hints in a dialog, each leading to its tour. The check
reads the source in full as before and spends the hint; taking a change in
stays the confirmed reimport. No tour is ever changed or deleted.

The check only asks through your own connection, only about trails your
account imported into trips you can still open, and stops when you switch it
off, remove the connection, wanderer refuses the token (until a test passes
or a new token is saved) or the admin switches wanderer off; hints and
snapshots stay. A run asks at most 25 connections for at most 10 records
each, one after another, and an instance that fails (an outage, a rate
limit) is left alone for an hour, doubling up to two days.

Server: migration 046120 adds the switch and the run bookkeeping to
wanderer_connections and the tour_source_hints table (one row per snapshot,
for its importer, gone with it). WandererWatchService and an hourly
wanderer-watch job on the CronRegistrar live in the tours domain and read
through WandererService; GET and PUT /api/integrations/wanderer/watch and GET
/api/trips/:tripId/tours/wanderer/sources/:placeId/hint (place_edit, like the
check) answer with @trek/shared contracts. A new user notification event,
wanderer_source_hint, can be muted per channel. Saving a new token now clears
what the last check said about the old one. There is no MCP tool, as for the
rest of the personal wanderer connection. All 27 locales carry the strings,
and the admin wiki page says what the check does.
This commit is contained in:
Maurice
2026-10-20 14:40:50 +02:00
parent aacc7f2c8d
commit 2e1dca9fe9
202 changed files with 4791 additions and 57 deletions
+9
View File
@@ -1,6 +1,7 @@
import type {
AdminFeatureToggleResponse,
TourSourceCheckResponse,
TourSourceHintResponse,
WandererConnection,
WandererImportPreview,
WandererImportRequest,
@@ -11,6 +12,7 @@ import type {
WandererSettingsRequest,
WandererTestRequest,
WandererTestResponse,
WandererWatch,
} from '@trek/shared';
import apiClient from './client';
@@ -31,6 +33,10 @@ export const wandererApi = {
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
/** WAN-02: the optional background watch of the connection, with the open hints. */
getWatch: (): Promise<WandererWatch> => apiClient.get('/integrations/wanderer/watch').then((r) => r.data),
setWatch: (enabled: boolean): Promise<WandererWatch> =>
apiClient.put('/integrations/wanderer/watch', { enabled }).then((r) => r.data),
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
@@ -43,6 +49,9 @@ export const wandererApi = {
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
/** WAN-02: the caller's own background hint on a snapshot; the server only reads its own rows. */
sourceHint: (tripId: number, placeId: number, signal?: AbortSignal): Promise<TourSourceHintResponse> =>
apiClient.get(`/trips/${tripId}/tours/wanderer/sources/${placeId}/hint`, { signal }).then((r) => r.data),
};
/** The admin's switch for wanderer under the Tours addon. */
@@ -16,11 +16,14 @@ import {
} from './settingsKit';
import ToggleSwitch from './ToggleSwitch';
import { useWandererConnection } from './useWandererConnection';
import WandererWatchRow from './WandererWatchRow';
/**
* Settings → Integrations → wanderer: the reader's own trail database, from
* which the Tours workspace imports trails. Laid out like the Dawarich and
* AirTrail cards beside it; the behaviour is in `useWandererConnection`.
* Under the form, the optional background check of the imported trails
* (WAN-02), which switches on its own and needs no save.
*/
export default function WandererConnectionSection(): React.ReactElement {
const { t } = useTranslation();
@@ -136,6 +139,8 @@ export default function WandererConnectionSection(): React.ReactElement {
)}
</div>
)}
<WandererWatchRow connected={S.connected} />
</Section>
);
}
@@ -0,0 +1,101 @@
import userEvent from '@testing-library/user-event';
import type { WandererSourceHintItem, WandererWatch } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import WandererWatchRow from './WandererWatchRow';
const repo = vi.hoisted(() => ({ getWatch: vi.fn(), setWatch: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
const navigate = vi.hoisted(() => vi.fn());
vi.mock('../../repo/wandererRepo', () => ({ wandererRepo: repo }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('react-router', async () => ({
...(await vi.importActual<typeof import('react-router')>('react-router')),
useNavigate: () => navigate,
}));
const off: WandererWatch = {
enabled: false,
connected: true,
lastRunAt: null,
pausedUntil: null,
blocked: false,
hints: [],
};
const hints: WandererSourceHintItem[] = [
{
placeId: 7,
tripId: 3,
tripTitle: 'Alps',
tourName: 'Ridge',
state: 'changed',
changes: ['name'],
reason: null,
externalUpdated: '2026-10-14',
checkedAt: '2026-10-20T10:00:00Z',
},
{
placeId: 8,
tripId: 3,
tripTitle: 'Alps',
tourName: 'Gorge',
state: 'unverifiable',
changes: [],
reason: 'unavailable',
externalUpdated: null,
checkedAt: '2026-10-19T10:00:00Z',
},
];
beforeEach(() => {
vi.clearAllMocks();
});
describe('WandererWatchRow (WAN-02)', () => {
it('is off by default and dimmed, not hidden, without a connection', async () => {
repo.getWatch.mockResolvedValue({ ...off, connected: false });
render(<WandererWatchRow connected={false} />);
await waitFor(() => expect(repo.getWatch).toHaveBeenCalled());
const toggle = await screen.findByRole('button', { name: 'Check my trails for changes', pressed: false });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
expect(toggle.closest('[aria-disabled="true"]')).not.toBeNull();
expect(screen.queryByTestId('wanderer-watch-status')).toBeNull();
});
it('switches on at once and then says when it last looked', async () => {
repo.getWatch.mockResolvedValue(off);
repo.setWatch.mockResolvedValue({ ...off, enabled: true });
render(<WandererWatchRow connected />);
const toggle = await screen.findByRole('button', { name: 'Check my trails for changes', pressed: false });
await waitFor(() => expect(toggle.closest('[aria-disabled="true"]')).toBeNull());
await userEvent.click(toggle);
expect(repo.setWatch).toHaveBeenCalledWith(true);
expect(await screen.findByText('Not looked yet')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Hints/ })).toBeDisabled();
});
it('counts the open hints on the button and opens each tour from the dialog', async () => {
repo.getWatch.mockResolvedValue({ ...off, enabled: true, lastRunAt: '2026-10-20T10:00:00Z', hints });
render(<WandererWatchRow connected />);
expect(await screen.findByTestId('wanderer-hint-count')).toHaveTextContent('2');
await userEvent.click(screen.getByRole('button', { name: /Hints/ }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Hints on your trails')).toBeInTheDocument();
expect(within(dialog).getByText('2 open hints')).toBeInTheDocument();
const ridge = within(dialog).getByTestId('wanderer-hint-7');
expect(within(ridge).getByText('Changed')).toBeInTheDocument();
expect(within(ridge).getByText('Name')).toBeInTheDocument();
expect(within(within(dialog).getByTestId('wanderer-hint-8')).getByText('Trail out of reach')).toBeInTheDocument();
await userEvent.click(ridge);
expect(navigate).toHaveBeenCalledWith('/trips/3?tab=plan&place=7');
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('names a refused token instead of a last look', async () => {
repo.getWatch.mockResolvedValue({ ...off, enabled: true, blocked: true });
render(<WandererWatchRow connected />);
expect(await screen.findByText('Token refused')).toBeInTheDocument();
});
});
@@ -0,0 +1,97 @@
import { BellRing } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { InfoDot } from '../Tours/tourParts';
import WandererHintsDialog from '../Tours/wanderer/WandererHintsDialog';
import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit';
import ToggleSwitch from './ToggleSwitch';
import { useWandererWatch, type WandererWatchController } from './useWandererWatch';
/** The switch, dimmed with a word on why while there is no connection to watch with. */
function WatchSwitch({ W }: { W: WandererWatchController }) {
const { t } = useTranslation();
const toggle = (
<ToggleSwitch on={W.enabled} onToggle={() => void W.toggle()} label={t('wanderer.watch.label')} />
);
if (W.available) return toggle;
return (
<Tooltip label={t('wanderer.watch.notConnected')}>
<span className="inline-flex opacity-50" aria-disabled="true">
<span className="pointer-events-none inline-flex">{toggle}</span>
</span>
</Tooltip>
);
}
/** The open hints behind a button, their number as a badge on its corner; dimmed while there are none. */
function HintsButton({ W }: { W: WandererWatchController }) {
const { t } = useTranslation();
const count = W.hints.length;
return (
<Tooltip label={count ? t('wanderer.watch.hintsCount', { count }) : t('wanderer.watch.empty')}>
<span className="inline-flex">
<button
type="button"
onClick={W.openHints}
disabled={count === 0}
className={`${SETTINGS_BUTTON} relative`}
style={fs(12.5, 'body')}
>
<BellRing size={13} strokeWidth={2.2} />
{t('wanderer.watch.hints')}
{count > 0 && (
<span
aria-label={t('wanderer.watch.hintsCount', { count })}
data-testid="wanderer-hint-count"
className="absolute -end-1.5 -top-1.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-[color:var(--bg-card)]"
style={fs(10)}
>
{count}
</span>
)}
</button>
</span>
</Tooltip>
);
}
/**
* WAN-02 in the wanderer card: the optional background check of the user's
* own imported trails. Off until switched on; while on, what it last did and
* its open hints, which open in a dialog that leads to each tour.
*/
export default function WandererWatchRow({ connected }: { connected: boolean }): React.ReactElement {
const { t } = useTranslation();
const W = useWandererWatch(connected);
return (
<SettingRows>
<SettingRow
label={
<span className="inline-flex items-center gap-1.5">
{t('wanderer.watch.label')}
<InfoDot label={t('wanderer.watch.label')} description={t('wanderer.watch.info')} />
</span>
}
hint={t('wanderer.watch.hint')}
dimmed={W.saving}
control={<WatchSwitch W={W} />}
>
{W.status && (
<div className="flex flex-wrap items-center gap-2" data-testid="wanderer-watch-status">
<Tooltip label={W.status.why ?? ''} disabled={!W.status.why}>
<span className="inline-flex">
<StatusPill tone={W.status.tone}>{W.status.text}</StatusPill>
</span>
</Tooltip>
<span className="ms-auto">
<HintsButton W={W} />
</span>
</div>
)}
</SettingRow>
<WandererHintsDialog open={W.hintsOpen} hints={W.hints} onClose={W.closeHints} onOpenTour={W.openTour} />
</SettingRows>
);
}
@@ -27,6 +27,7 @@ export const EVENT_LABEL_KEYS: Record<string, string> = {
photos_shared: 'settings.notifyPhotosShared',
collab_message: 'settings.notifyCollabMessage',
packing_tagged: 'settings.notifyPackingTagged',
wanderer_source_hint: 'settings.notifyWandererSourceHint',
version_available: 'settings.notifyVersionAvailable',
}
@@ -0,0 +1,134 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import type { WandererSourceHintItem, WandererWatch } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useWandererWatch, watchStatusOf } from './useWandererWatch';
const repo = vi.hoisted(() => ({ getWatch: vi.fn(), setWatch: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
const navigate = vi.hoisted(() => vi.fn());
vi.mock('../../repo/wandererRepo', () => ({ wandererRepo: repo }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key),
locale: 'en',
}),
}));
vi.mock('react-router', async () => ({
...(await vi.importActual<typeof import('react-router')>('react-router')),
useNavigate: () => navigate,
}));
const t = (key: string) => key;
const off: WandererWatch = {
enabled: false,
connected: true,
lastRunAt: null,
pausedUntil: null,
blocked: false,
hints: [],
};
const hint: WandererSourceHintItem = {
placeId: 7,
tripId: 3,
tripTitle: 'Alps',
tourName: 'Ridge',
state: 'possible_change',
changes: [],
reason: null,
externalUpdated: '2026-10-14',
checkedAt: '2026-10-20T10:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
repo.getWatch.mockResolvedValue(off);
repo.setWatch.mockImplementation(async (enabled: boolean) => ({ ...off, enabled }));
});
describe('watchStatusOf (WAN-02)', () => {
const now = Date.parse('2026-10-20T12:00:00Z');
it('says nothing while the watch is off', () => {
expect(watchStatusOf(null, t, 'en', now)).toBeNull();
expect(watchStatusOf(off, t, 'en', now)).toBeNull();
});
it('names a refused token first, then a pause still running, then the last look', () => {
const on = { ...off, enabled: true };
expect(watchStatusOf({ ...on, blocked: true, pausedUntil: '2026-10-21T00:00:00Z' }, t, 'en', now)).toEqual({
tone: 'danger',
text: 'wanderer.watch.blocked',
why: 'wanderer.watch.blockedHint',
});
expect(watchStatusOf({ ...on, pausedUntil: '2026-10-20T14:00:00Z' }, t, 'en', now)).toMatchObject({
tone: 'warning',
text: 'wanderer.watch.paused',
why: 'wanderer.watch.pausedHint',
});
expect(
watchStatusOf({ ...on, pausedUntil: '2026-10-20T11:00:00Z', lastRunAt: '2026-10-20T10:00:00Z' }, t, 'en', now)
).toEqual({ tone: 'neutral', text: 'wanderer.watch.lastRun', why: null });
expect(watchStatusOf(on, t, 'en', now)).toEqual({ tone: 'neutral', text: 'wanderer.watch.neverRun', why: null });
});
});
describe('useWandererWatch (WAN-02)', () => {
it('loads with the card and again when the connection comes or goes', async () => {
const { result, rerender } = renderHook(({ connected }) => useWandererWatch(connected), {
initialProps: { connected: true },
});
await waitFor(() => expect(result.current.available).toBe(true));
expect(result.current.enabled).toBe(false);
expect(result.current.status).toBeNull();
rerender({ connected: false });
await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2));
});
it('switches at once, and says so either way', async () => {
const { result } = renderHook(() => useWandererWatch(true));
await waitFor(() => expect(result.current.available).toBe(true));
await act(async () => {
await result.current.toggle();
});
expect(repo.setWatch).toHaveBeenCalledWith(true);
expect(result.current.enabled).toBe(true);
expect(toast.success).toHaveBeenCalledWith('wanderer.watch.on');
repo.setWatch.mockRejectedValueOnce(new Error('down'));
await act(async () => {
await result.current.toggle();
});
expect(toast.error).toHaveBeenCalledWith('wanderer.watch.failed');
expect(result.current.enabled).toBe(true);
});
it('leaves the switch alone without a connection, or when the watch could not be read', async () => {
repo.getWatch.mockResolvedValueOnce({ ...off, connected: false });
const { result } = renderHook(() => useWandererWatch(false));
await waitFor(() => expect(repo.getWatch).toHaveBeenCalled());
await act(async () => {
await result.current.toggle();
});
expect(repo.setWatch).not.toHaveBeenCalled();
repo.getWatch.mockRejectedValueOnce(new Error('offline'));
const failed = renderHook(() => useWandererWatch(true));
await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2));
expect(failed.result.current.available).toBe(false);
});
it('lists the hints and opens the tour one is about', async () => {
repo.getWatch.mockResolvedValue({ ...off, enabled: true, hints: [hint] });
const { result } = renderHook(() => useWandererWatch(true));
await waitFor(() => expect(result.current.hints).toEqual([hint]));
act(() => result.current.openHints());
expect(result.current.hintsOpen).toBe(true);
act(() => result.current.openTour(hint));
expect(navigate).toHaveBeenCalledWith('/trips/3?tab=plan&place=7');
expect(result.current.hintsOpen).toBe(false);
act(() => result.current.openHints());
act(() => result.current.closeHints());
expect(result.current.hintsOpen).toBe(false);
});
});
@@ -0,0 +1,113 @@
import type { WandererSourceHintItem, WandererWatch } from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n';
import { wandererRepo } from '../../repo/wandererRepo';
import { useToast } from '../shared/Toast';
import { hintWhen } from '../Tours/wanderer/sourceHintParts';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** What the watch last did, as one pill: refused, waiting after errors, last looked, or not yet. */
interface WandererWatchStatus {
tone: 'neutral' | 'warning' | 'danger';
text: string;
/** Why, for the tooltip; null where the text says it all. */
why: string | null;
}
/** The pill for a switched-on watch; null while it is off, since then nothing runs to report on. */
export function watchStatusOf(
watch: WandererWatch | null,
t: Translate,
locale: string,
now = Date.now()
): WandererWatchStatus | null {
if (!watch?.enabled) return null;
if (watch.blocked) return { tone: 'danger', text: t('wanderer.watch.blocked'), why: t('wanderer.watch.blockedHint') };
const pausedUntil = watch.pausedUntil ? new Date(watch.pausedUntil).getTime() : Number.NaN;
if (pausedUntil > now) {
return {
tone: 'warning',
text: t('wanderer.watch.paused', { time: hintWhen(watch.pausedUntil, locale) }),
why: t('wanderer.watch.pausedHint'),
};
}
const lastRun = hintWhen(watch.lastRunAt, locale);
return {
tone: 'neutral',
text: lastRun ? t('wanderer.watch.lastRun', { date: lastRun }) : t('wanderer.watch.neverRun'),
why: null,
};
}
/**
* WAN-02: the background watch in the wanderer card. It loads with the card
* and again whenever the connection comes or goes, switches on and off at
* once (there is nothing to save with the form), and opens the tour a hint is
* about. Online-only like the rest of the connection: the watch runs on the
* server and its hints are read from there.
*/
export function useWandererWatch(connected: boolean) {
const { t, locale } = useTranslation();
const toast = useToast();
const navigate = useNavigate();
const [watch, setWatch] = useState<WandererWatch | null>(null);
const [saving, setSaving] = useState(false);
const [hintsOpen, setHintsOpen] = useState(false);
useEffect(() => {
let cancelled = false;
wandererRepo.getWatch().then(
(next) => {
if (!cancelled) setWatch(next);
},
() => {
// The card above already says when the connection cannot be read; without
// an answer the switch simply stays dimmed.
if (!cancelled) setWatch(null);
}
);
return () => {
cancelled = true;
};
}, [connected]);
const toggle = useCallback(async () => {
if (!watch?.connected || saving) return;
setSaving(true);
try {
const next = await wandererRepo.setWatch(!watch.enabled);
setWatch(next);
toast.success(next.enabled ? t('wanderer.watch.on') : t('wanderer.watch.off'));
} catch {
toast.error(t('wanderer.watch.failed'));
} finally {
setSaving(false);
}
}, [saving, t, toast, watch]);
const openTour = useCallback(
(hint: WandererSourceHintItem) => {
setHintsOpen(false);
navigate(`/trips/${hint.tripId}?tab=plan&place=${hint.placeId}`);
},
[navigate]
);
return {
/** The switch can act: the server knows a connection to watch with. */
available: !!watch?.connected,
enabled: !!watch?.enabled,
saving,
status: watchStatusOf(watch, t, locale),
hints: watch?.hints ?? [],
hintsOpen,
openHints: useCallback(() => setHintsOpen(true), []),
closeHints: useCallback(() => setHintsOpen(false), []),
openTour,
toggle,
};
}
export type WandererWatchController = ReturnType<typeof useWandererWatch>;
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { TourProvenance, TourSourceCheckResponse } from '@trek/shared';
import type { TourProvenance, TourSourceCheckResponse, TourSourceHint } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import TourProvenanceSection from './TourProvenanceSection';
import type { SourceCheck } from './useSourceCheck';
@@ -158,4 +158,51 @@ describe('TourProvenanceSection (P2C-02, MF-03)', () => {
expect(screen.getByText('tours.provenance.body.unavailable')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /tours.provenance.reimport/ })).toBeNull();
});
it('shows the background hint with its own check button until the source answered (WAN-02)', () => {
const onCheck = vi.fn();
const wanderer: TourProvenance = {
...base,
origin: 'wanderer',
source: {
kind: 'wanderer',
instance: 'https://t.example',
externalId: 't1',
externalUrl: null,
externalUpdated: null,
importedAt: null,
},
};
const hint: TourSourceHint = {
state: 'changed',
changes: ['description'],
reason: null,
externalUpdated: '2026-10-14',
checkedAt: '2026-10-20T10:00:00Z',
};
const { rerender } = render(
<TourProvenanceSection provenance={wanderer} check={check({ check: onCheck })} hint={hint} />
);
const notice = screen.getByTestId('tour-source-hint');
expect(notice).toHaveTextContent('wanderer.watch.noticeLabel');
expect(screen.getByTestId('source-hint-state')).toHaveAttribute('data-state', 'changed');
expect(screen.getByText('tours.provenance.change.description')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: /tours.provenance.check$/ });
expect(buttons).toHaveLength(1);
fireEvent.click(buttons[0]);
expect(onCheck).toHaveBeenCalledTimes(1);
rerender(
<TourProvenanceSection
provenance={wanderer}
check={check({ answered: true })}
hint={{ ...hint, state: 'unverifiable', changes: [], reason: 'unavailable' }}
/>
);
expect(screen.queryByTestId('tour-source-hint')).toBeNull();
expect(screen.getAllByRole('button', { name: /tours.provenance.check$/ })).toHaveLength(1);
rerender(<TourProvenanceSection provenance={wanderer} hint={hint} />);
expect(screen.queryByTestId('tour-source-hint')).toBeNull();
});
});
@@ -1,4 +1,4 @@
import type { TourListItem, TourProvenance, TourSourceCheckResponse } from '@trek/shared';
import type { TourListItem, TourProvenance, TourSourceCheckResponse, TourSourceHint } from '@trek/shared';
import { CopyPlus, Download, ExternalLink, FileDown, Loader2, PenLine, RefreshCw } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
@@ -9,8 +9,10 @@ import ConfirmDialog from '../../shared/ConfirmDialog';
import { DialogSection, fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, InfoDot } from '../tourParts';
import { hintWhen, SourceHintPills, SourceHintTile } from '../wanderer/sourceHintParts';
import WandererMark from '../wanderer/WandererMark';
import { type SourceCheck, useSourceCheck } from './useSourceCheck';
import { useSourceHint } from './useSourceHint';
import { useTourProvenance } from './useTourProvenance';
type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -177,10 +179,73 @@ function CheckResult({ check, locale, t }: { check: SourceCheck; locale: string;
);
}
/** The check button, in the head of the section or on the hint it answers. */
function CheckButton({ check, t }: { check: SourceCheck; t: Translate }) {
return (
<Tooltip label={t('tours.provenance.checkHint')}>
<span className="inline-flex">
<CardAction
icon={
check.checking ? <Loader2 size={12} className="animate-spin" /> : <RefreshCw size={12} strokeWidth={2.2} />
}
onClick={() => void check.check()}
disabled={check.checking}
>
{t('tours.provenance.check')}
</CardAction>
</span>
</Tooltip>
);
}
/**
* WAN-02: what the reader's background watch found about this snapshot's
* source. A hint, never the answer: it leads to the check, which reads the
* source in full and from which a change can be taken in.
*/
function HintNotice({
hint,
check,
locale,
t,
}: {
hint: TourSourceHint;
check: SourceCheck;
locale: string;
t: Translate;
}) {
const when = hintWhen(hint.checkedAt, locale);
return (
<div
role="status"
data-testid="tour-source-hint"
className="flex items-center gap-2.5 border-t border-edge-faint px-3 py-2.5"
>
<SourceHintTile state={hint.state} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="text-content-faint" style={fs(11)}>
{t('wanderer.watch.noticeLabel')}
</span>
<div className="flex flex-wrap items-center gap-1.5">
<SourceHintPills hint={hint} />
{when && (
<span className="text-content-faint" style={fs(11)}>
{t('wanderer.watch.checkedAt', { date: when })}
</span>
)}
</div>
</div>
<CheckButton check={check} t={t} />
</div>
);
}
interface TourProvenanceSectionProps {
provenance: TourProvenance;
/** Given for a wanderer snapshot the user may change: the check of its source and the reimport. */
check?: SourceCheck;
/** WAN-02: the reader's own background hint on the source, shown until the check answers. */
hint?: TourSourceHint | null;
}
/**
@@ -189,13 +254,15 @@ interface TourProvenanceSectionProps {
* came in. For a wanderer snapshot, MF-03: the check of its source, on purpose
* and read only, and from a change the confirmed reimport as a new snapshot.
*/
export default function TourProvenanceSection({ provenance, check }: TourProvenanceSectionProps) {
export default function TourProvenanceSection({ provenance, check, hint }: TourProvenanceSectionProps) {
const { t, locale } = useTranslation();
const { source } = provenance;
const imported = source.kind !== 'trek';
const importedAt = dayOf((source.kind === 'wanderer' ? source.importedAt : null) ?? provenance.createdAt, locale);
const externalUpdated = source.kind === 'wanderer' ? dayOf(source.externalUpdated, locale) : null;
const local = <LocalState provenance={provenance} t={t} />;
// The hint carries the check button until the source answered a check, which spends it.
const openHint = check && hint && !check.answered ? hint : null;
const sourceIcon =
source.kind === 'gpx' ? (
<FileDown size={13} strokeWidth={2} />
@@ -209,27 +276,7 @@ export default function TourProvenanceSection({ provenance, check }: TourProvena
<DialogSection
className="flex-none"
label={t('tours.provenance.label')}
action={
check && (
<Tooltip label={t('tours.provenance.checkHint')}>
<span className="inline-flex">
<CardAction
icon={
check.checking ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} strokeWidth={2.2} />
)
}
onClick={() => void check.check()}
disabled={check.checking}
>
{t('tours.provenance.check')}
</CardAction>
</span>
</Tooltip>
)
}
action={check && !openHint && <CheckButton check={check} t={t} />}
>
<div className={`${BOX} divide-y divide-edge-faint`}>
<ProvenanceRow icon={sourceIcon} label={t('tours.provenance.source')}>
@@ -267,6 +314,7 @@ export default function TourProvenanceSection({ provenance, check }: TourProvena
{local}
</ProvenanceRow>
)}
{check && openHint && <HintNotice hint={openHint} check={check} locale={locale} t={t} />}
{check && <CheckResult check={check} locale={locale} t={t} />}
</div>
{check && (
@@ -287,7 +335,8 @@ export default function TourProvenanceSection({ provenance, check }: TourProvena
/**
* The section as the tour's detail places it: reads the provenance with the
* detail, and offers the source check only on a wanderer snapshot the user
* may change, where a reimport has somewhere to go.
* may change, where a reimport has somewhere to go, with the reader's own
* background hint on it (WAN-02).
*/
export function TourProvenanceBlock({
tripId,
@@ -302,7 +351,15 @@ export function TourProvenanceBlock({
}) {
const provenance = useTourProvenance(tripId, tour);
const check = useSourceCheck({ tripId, placeId: tour.place_id, onReimported });
const fromWanderer = (provenance?.origin ?? tour.origin) === 'wanderer';
const hint = useSourceHint({ tripId, placeId: tour.place_id, enabled: canCheck && fromWanderer });
if (!provenance) return null;
const checkable = canCheck && provenance.origin === 'wanderer';
return <TourProvenanceSection provenance={provenance} check={checkable ? check : undefined} />;
return (
<TourProvenanceSection
provenance={provenance}
check={checkable ? check : undefined}
hint={checkable ? hint : null}
/>
);
}
@@ -100,4 +100,33 @@ describe('useSourceCheck (MF-03)', () => {
rerender({ placeId: 8 });
expect(result.current.result).toBeNull();
});
it('remembers that the source answered for this tour, through a reimport, and not for another (WAN-02)', async () => {
const onReimported = vi.fn();
const { result, rerender } = renderHook(({ placeId }) => useSourceCheck({ tripId: 1, placeId, onReimported }), {
initialProps: { placeId: 7 },
});
expect(result.current.answered).toBe(false);
repo.checkSource.mockRejectedValueOnce({ code: 'unreachable' });
await act(async () => {
await result.current.check();
});
expect(result.current.answered).toBe(false);
await act(async () => {
await result.current.check();
});
expect(result.current.answered).toBe(true);
act(() => result.current.askReimport());
await act(async () => {
await result.current.reimport();
});
expect(result.current.result).toBeNull();
expect(result.current.answered).toBe(true);
rerender({ placeId: 8 });
expect(result.current.answered).toBe(false);
act(() => result.current.askReimport());
expect(result.current.answered).toBe(false);
});
});
@@ -25,19 +25,35 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt
const [reimporting, setReimporting] = useState(false);
// The answer and the question belong to the tour they were asked for:
// another tour shown in the same dialog starts unchecked.
const [state, setState] = useState<{ placeId: number; result: TourSourceCheckResponse | null; confirming: boolean }>({
placeId,
result: null,
confirming: false,
});
// `answered` stays once the source answered for this tour, through a reimport
// too: the server then holds no background hint on it any more (WAN-02).
const [state, setState] = useState<{
placeId: number;
result: TourSourceCheckResponse | null;
confirming: boolean;
answered: boolean;
}>({ placeId, result: null, confirming: false, answered: false });
const result = state.placeId === placeId ? state.result : null;
const confirming = state.placeId === placeId && state.confirming;
const answered = state.placeId === placeId && state.answered;
const setResult = useCallback(
(next: TourSourceCheckResponse | null) => setState({ placeId, result: next, confirming: false }),
(next: TourSourceCheckResponse | null) =>
setState((current) => ({
placeId,
result: next,
confirming: false,
answered: (current.placeId === placeId && current.answered) || next !== null,
})),
[placeId]
);
const setConfirming = useCallback(
(next: boolean) => setState((current) => ({ ...current, placeId, confirming: next })),
(next: boolean) =>
setState((current) => ({
...current,
placeId,
confirming: next,
answered: current.placeId === placeId && current.answered,
})),
[placeId]
);
@@ -76,6 +92,8 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt
checking,
result,
check,
/** The source answered a check of this tour; a background hint on it is spent. */
answered,
/** A reimport can be offered: the trail was read and differs, and someone takes the new snapshot in. */
canReimport: !!result?.reimport && !!onReimported,
confirming,
@@ -0,0 +1,47 @@
import { renderHook, waitFor } from '@testing-library/react';
import type { TourSourceHint } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSourceHint } from './useSourceHint';
const repo = vi.hoisted(() => ({ sourceHint: vi.fn() }));
vi.mock('../../../repo/wandererRepo', () => ({ wandererRepo: repo }));
const hint: TourSourceHint = {
state: 'possible_change',
changes: [],
reason: null,
externalUpdated: '2026-10-14',
checkedAt: '2026-10-20T10:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
repo.sourceHint.mockResolvedValue(hint);
});
describe('useSourceHint (WAN-02)', () => {
it('reads the hint of the tour it is asked for, and asks again for another tour', async () => {
const { result, rerender } = renderHook(({ placeId }) => useSourceHint({ tripId: 1, placeId, enabled: true }), {
initialProps: { placeId: 7 },
});
expect(result.current).toBeNull();
await waitFor(() => expect(result.current).toEqual(hint));
expect(repo.sourceHint).toHaveBeenCalledWith(1, 7, expect.any(AbortSignal));
repo.sourceHint.mockResolvedValue(null);
rerender({ placeId: 8 });
expect(result.current).toBeNull();
await waitFor(() => expect(repo.sourceHint).toHaveBeenCalledWith(1, 8, expect.any(AbortSignal)));
});
it('asks nothing where the hint could lead nowhere, and shows none after a failed read', async () => {
const off = renderHook(() => useSourceHint({ tripId: 1, placeId: 7, enabled: false }));
expect(off.result.current).toBeNull();
expect(repo.sourceHint).not.toHaveBeenCalled();
repo.sourceHint.mockRejectedValue(new Error('down'));
const failed = renderHook(() => useSourceHint({ tripId: 1, placeId: 7, enabled: true }));
await waitFor(() => expect(repo.sourceHint).toHaveBeenCalled());
expect(failed.result.current).toBeNull();
});
});
@@ -0,0 +1,39 @@
import type { TourSourceHint } from '@trek/shared';
import { useEffect, useState } from 'react';
import { wandererRepo } from '../../../repo/wandererRepo';
/**
* WAN-02: the reader's own background hint on a wanderer snapshot, read with
* the tour's detail when it can lead anywhere (a snapshot the reader may
* check). Null while it loads, offline, on a failed read and when the watch
* left none: a missing hint only means there is nothing to point at, never
* that the trail is unchanged.
*/
export function useSourceHint({
tripId,
placeId,
enabled,
}: {
tripId: number;
placeId: number;
enabled: boolean;
}): TourSourceHint | null {
const [state, setState] = useState<{ key: string; hint: TourSourceHint | null } | null>(null);
const key = `${tripId}|${placeId}`;
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
wandererRepo.sourceHint(tripId, placeId, controller.signal).then(
(hint) => {
if (!controller.signal.aborted) setState({ key, hint });
},
() => {
if (!controller.signal.aborted) setState({ key, hint: null });
}
);
return () => controller.abort();
}, [enabled, key, tripId, placeId]);
return enabled && state?.key === key ? state.hint : null;
}
@@ -0,0 +1,119 @@
import type { WandererSourceHintItem } from '@trek/shared';
import { ChevronRight } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../../i18n';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { hintWhen, SourceHintPills, SourceHintTile } from './sourceHintParts';
import WandererMark from './WandererMark';
/** One hint: the tour and its trip, what the watch found, when, and the way to the tour. */
function HintRow({ hint, onOpen }: { hint: WandererSourceHintItem; onOpen: (hint: WandererSourceHintItem) => void }) {
const { t, locale } = useTranslation();
const when = hintWhen(hint.checkedAt, locale);
return (
<li>
<button
type="button"
onClick={() => onOpen(hint)}
data-testid={`wanderer-hint-${hint.placeId}`}
className="group flex w-full items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card px-3 py-2.5 text-start transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<SourceHintTile state={hint.state} />
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 truncate font-semibold text-content" style={fs(13, 'body')}>
{hint.tourName}
</span>
<span className="min-w-0 truncate text-content-faint" style={fs(11.5)}>
{hint.tripTitle}
</span>
</span>
<span className="flex flex-wrap items-center gap-1.5">
<SourceHintPills hint={hint} />
{when && (
<span className="text-content-faint" style={fs(11)}>
{t('wanderer.watch.checkedAt', { date: when })}
</span>
)}
</span>
</span>
<span className="sr-only">{t('wanderer.watch.openTour')}</span>
<ChevronRight
size={15}
strokeWidth={2.2}
aria-hidden="true"
className="flex-none text-content-faint transition-transform group-hover:translate-x-0.5 rtl:rotate-180 rtl:group-hover:-translate-x-0.5"
/>
</button>
</li>
);
}
/**
* WAN-02: the open hints of the user's wanderer watch, each leading to its
* tour, where the source check shows what really changed. Reading only: the
* dialog changes nothing, and a hint goes once its source was checked.
*/
export default function WandererHintsDialog({
open,
hints,
onClose,
onOpenTour,
}: {
open: boolean;
hints: readonly WandererSourceHintItem[];
onClose: () => void;
onOpenTour: (hint: WandererSourceHintItem) => void;
}) {
const { t } = useTranslation();
const labelId = useId();
return (
<DialogShell
open={open}
onClose={onClose}
labelledBy={labelId}
width="detail"
header={
<DialogHeader
tile={
<DialogTile>
<WandererMark size={22} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('wanderer.watch.dialogTitle')}
sub={t('wanderer.watch.hintsCount', { count: hints.length })}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.close')}</DialogButton>
</DialogFooter>
}
>
{hints.length === 0 ? (
<p className="m-0 text-center text-content-muted" style={fs(12.5, 'body')}>
{t('wanderer.watch.empty')}
</p>
) : (
<ul className="m-0 flex list-none flex-col gap-2 p-0">
{hints.map((hint) => (
<HintRow key={hint.placeId} hint={hint} onOpen={onOpenTour} />
))}
</ul>
)}
</DialogShell>
);
}
@@ -0,0 +1,24 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { hintWhen, SourceHintPills, SourceHintTile } from './sourceHintParts';
// Rendered without the translation provider: every text reads as its key.
describe('sourceHintParts (WAN-02)', () => {
it('names a possible change apart from a proven one, with the reason a source could not be judged', () => {
const { rerender } = render(<SourceHintPills hint={{ state: 'possible_change', changes: [], reason: null }} />);
expect(screen.getByTestId('source-hint-state')).toHaveAttribute('data-state', 'possible_change');
expect(screen.getByText('wanderer.watch.state.possible_change')).toBeInTheDocument();
rerender(<SourceHintPills hint={{ state: 'unverifiable', changes: [], reason: 'no_change_time' }} />);
expect(screen.getByText('wanderer.watch.state.unverifiable')).toBeInTheDocument();
expect(screen.getByText('wanderer.watch.reason.no_change_time')).toBeInTheDocument();
});
it('marks the tile with the state, and reads a stored instant or nothing', () => {
const { container } = render(<SourceHintTile state="changed" />);
expect(container.querySelector('.text-warning')).not.toBeNull();
expect(hintWhen(null, 'en')).toBeNull();
expect(hintWhen('not a time', 'en')).toBeNull();
expect(hintWhen('2026-10-20T10:00:00Z', 'en')).toEqual(expect.stringContaining('Oct'));
});
});
@@ -0,0 +1,63 @@
import type { TourSourceHint, TourSourceHintState } from '@trek/shared';
import { HelpCircle, History, PenLine, type LucideIcon } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
import { Tooltip } from '../../shared/Tooltip';
import WandererMark from './WandererMark';
/**
* WAN-02: how a hint of the wanderer watch looks wherever it shows (the
* settings list, a tour's provenance): one tone and one icon per state, so a
* possible change never reads like a proven one.
*/
const HINT_LOOK: Record<TourSourceHintState, { tone: 'info' | 'warning' | 'neutral'; text: string; Icon: LucideIcon }> =
{
possible_change: { tone: 'info', text: 'text-info', Icon: History },
changed: { tone: 'warning', text: 'text-warning', Icon: PenLine },
unverifiable: { tone: 'neutral', text: 'text-content-muted', Icon: HelpCircle },
};
/** The state as a pill with its meaning in the tooltip, then what the record proves changed or why it could not judge. */
export function SourceHintPills({ hint }: { hint: Pick<TourSourceHint, 'state' | 'changes' | 'reason'> }) {
const { t } = useTranslation();
const look = HINT_LOOK[hint.state];
return (
<>
<Tooltip label={t(`wanderer.watch.stateHint.${hint.state}`)}>
<span className="inline-flex" data-testid="source-hint-state" data-state={hint.state}>
<SoftPill tone={look.tone} icon={<look.Icon size={10} strokeWidth={2.4} />}>
{t(`wanderer.watch.state.${hint.state}`)}
</SoftPill>
</span>
</Tooltip>
{hint.changes.map((part) => (
<SoftPill key={part}>{t(`tours.provenance.change.${part}`)}</SoftPill>
))}
{hint.reason && <SoftPill>{t(`wanderer.watch.reason.${hint.reason}`)}</SoftPill>}
</>
);
}
/** The wanderer emblem on a raised round tile, with the state's icon as a badge on its corner. */
export function SourceHintTile({ state }: { state: TourSourceHintState }) {
const look = HINT_LOOK[state];
return (
<span className="relative grid h-9 w-9 flex-none place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint">
<WandererMark size={17} />
<span
aria-hidden="true"
className={`absolute -bottom-1 -end-1 grid h-4 w-4 place-items-center rounded-full bg-surface-card shadow-sm ring-2 ring-[color:var(--bg-card)] ${look.text}`}
>
<look.Icon size={9} strokeWidth={2.6} />
</span>
</span>
);
}
/** A stored instant as the reader's day and time; the hints say when the watch last looked. */
export function hintWhen(iso: string | null, locale: string): string | null {
if (!iso) return null;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
}
@@ -0,0 +1,44 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { usePlaceDeepLink } from './usePlaceDeepLink';
function setup(query: string, placeIds: number[]) {
const searchParams = new URLSearchParams(query);
const setSearchParams = vi.fn((update: (params: URLSearchParams) => URLSearchParams) => update(searchParams));
const selectPlace = vi.fn();
const hook = renderHook(
({ ids }) =>
usePlaceDeepLink({
searchParams,
setSearchParams: setSearchParams as never,
placeIds: ids,
selectPlace,
}),
{ initialProps: { ids: placeIds } }
);
return { searchParams, setSearchParams, selectPlace, hook };
}
describe('usePlaceDeepLink (WAN-02)', () => {
it('opens the linked place of the trip and leaves the URL', () => {
const { searchParams, selectPlace, setSearchParams } = setup('tab=plan&place=7', [3, 7]);
expect(selectPlace).toHaveBeenCalledWith(7);
expect(searchParams.has('place')).toBe(false);
expect(searchParams.get('tab')).toBe('plan');
expect(setSearchParams).toHaveBeenCalledWith(expect.any(Function), { replace: true });
});
it('waits for the places, then drops an id the trip does not hold', () => {
const { searchParams, selectPlace, hook } = setup('place=9', []);
expect(searchParams.get('place')).toBe('9');
hook.rerender({ ids: [3, 7] });
expect(selectPlace).not.toHaveBeenCalled();
expect(searchParams.has('place')).toBe(false);
});
it('does nothing without a link', () => {
const { setSearchParams, selectPlace } = setup('tab=plan', [3]);
expect(setSearchParams).not.toHaveBeenCalled();
expect(selectPlace).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import type { PlannerSearchParams } from './plannerTypes';
interface PlaceDeepLinkOptions {
searchParams: PlannerSearchParams[0];
setSearchParams: PlannerSearchParams[1];
/** The places of this trip, empty until they are loaded. */
placeIds: readonly number[];
selectPlace: (placeId: number) => void;
}
/**
* `?place=<id>` opens that place of the trip, a tour in its detail: the link a
* notification about one of them carries (WAN-02). It waits until the trip's
* places are there, then leaves the URL, so a reload or the way back does not
* open it again. An id the trip does not hold is dropped without a word.
*/
export function usePlaceDeepLink({ searchParams, setSearchParams, placeIds, selectPlace }: PlaceDeepLinkOptions): void {
const requested = searchParams.get('place');
const known = requested !== null && placeIds.includes(Number(requested));
const loaded = placeIds.length > 0;
useEffect(() => {
if (requested === null || (!known && !loaded)) return;
if (known) selectPlace(Number(requested));
setSearchParams(
(params) => {
params.delete('place');
return params;
},
{ replace: true }
);
}, [requested, known, loaded, selectPlace, setSearchParams]);
}
@@ -48,6 +48,7 @@ import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
import { useDayDelete } from './useDayDelete'
import { useDayAdd } from './useDayAdd'
import { usePlannerDialogs } from './usePlannerDialogs'
import { usePlaceDeepLink } from './usePlaceDeepLink'
import { usePlannerMapView } from './usePlannerMapView'
import { useMapPlaces } from './useMapPlaces'
import { useRoadtripFeed } from './useRoadtripFeed'
@@ -308,6 +309,12 @@ export function useTripPlanner() {
// takes the request once, then hands it back so a later opening stays plain.
const [stayPickerDayId, setStayPickerDayId] = useState<number | null>(null)
const plannerDialogs = usePlannerDialogs({ tripId, tripActions, searchParams, setSearchParams })
// Only this trip's: the store may still hold the last trip's places for a moment.
const allPlaceIds = useMemo(
() => allPlaces.filter(place => place.trip_id === tripId).map(place => place.id),
[allPlaces, tripId],
)
usePlaceDeepLink({ searchParams, setSearchParams, placeIds: allPlaceIds, selectPlace: setSelectedPlaceId })
// The dialog state this hook reads further down as well as returning it.
const {
setShowPlaceForm, editingPlace, setEditingPlace, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
+21
View File
@@ -65,6 +65,27 @@ describe('wandererRepo', () => {
).rejects.toBeInstanceOf(WandererOfflineError);
});
it('WAN-02: reads and switches the watch, and a snapshot hint only while online', async () => {
const watch = { enabled: true, connected: true, lastRunAt: null, pausedUntil: null, blocked: false, hints: [] };
const sent: unknown[] = [];
const hint = { state: 'possible_change', changes: [], reason: null, externalUpdated: null, checkedAt: 'x' };
server.use(
http.get('/api/integrations/wanderer/watch', () => HttpResponse.json(watch)),
http.put('/api/integrations/wanderer/watch', async ({ request }) => {
sent.push(await request.json());
return HttpResponse.json(watch);
}),
http.get('/api/trips/3/tours/wanderer/sources/70/hint', () => HttpResponse.json({ hint }))
);
await expect(wandererRepo.getWatch()).resolves.toEqual(watch);
await wandererRepo.setWatch(false);
expect(sent).toEqual([{ enabled: false }]);
await expect(wandererRepo.sourceHint(3, 70)).resolves.toEqual(hint);
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
await expect(wandererRepo.sourceHint(3, 70)).resolves.toBeNull();
});
it('RS-03: reads the reason of a failed call from the server, the offline case or nothing', () => {
expect(wandererErrorCode(new WandererOfflineError())).toBe('offline');
expect(wandererErrorCode({ response: { data: { code: 'not_own_trail' } } })).toBe('not_own_trail');
+8
View File
@@ -14,6 +14,8 @@ export const wandererRepo = {
saveSettings: wandererApi.saveSettings,
disconnect: wandererApi.disconnect,
test: wandererApi.test,
getWatch: wandererApi.getWatch,
setWatch: wandererApi.setWatch,
async search(tripId: number, body: WandererSearchRequest, signal?: AbortSignal) {
if (isEffectivelyOffline()) throw new WandererOfflineError();
@@ -30,6 +32,12 @@ export const wandererRepo = {
return await wandererApi.checkSource(tripId, placeId);
},
/** WAN-02: the caller's own background hint on a snapshot; offline there is none to show. */
async sourceHint(tripId: number, placeId: number, signal?: AbortSignal) {
if (isEffectivelyOffline()) return null;
return (await wandererApi.sourceHint(tripId, placeId, signal)).hint;
},
async importTrail(tripId: number, body: WandererImportRequest) {
if (isEffectivelyOffline()) throw new WandererOfflineError();
const result = await wandererApi.importTrail(tripId, body);
+21 -1
View File
@@ -1,4 +1,9 @@
import { tourPackingLinkResponseSchema, tourVisitListResponseSchema } from '@trek/shared';
import {
tourPackingLinkResponseSchema,
tourSourceHintResponseSchema,
tourVisitListResponseSchema,
wandererWatchSchema,
} from '@trek/shared';
import { contractHandler } from '../contract';
export const toursHandlers = [
@@ -10,4 +15,19 @@ export const toursHandlers = [
contractHandler('get', '/api/trips/:id/tours/:placeId/packing-link', { response: tourPackingLinkResponseSchema }, () => ({
packing_link: null,
})),
// WAN-02: the wanderer watch is off and leaves no hint on a tour unless a test says so.
contractHandler('get', '/api/integrations/wanderer/watch', { response: wandererWatchSchema }, () => ({
enabled: false,
connected: false,
lastRunAt: null,
pausedUntil: null,
blocked: false,
hints: [],
})),
contractHandler(
'get',
'/api/trips/:id/tours/wanderer/sources/:placeId/hint',
{ response: tourSourceHintResponseSchema },
() => ({ hint: null })
),
];
+1
View File
@@ -115,6 +115,7 @@
"TourObstacleReviews": "tours",
"TourStationVisits": "tours",
"TourPackingLinks": "tours",
"TourSourceHints": "tours",
"TourSources": "tours",
"TourTypes": "tours",
"TourWaypoints": "tours",
@@ -0,0 +1,53 @@
import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { TourSourceHintsRepository } from '../repositories/TourSourceHints.repository';
import { TourSources } from './TourSources.entity';
import { Users } from './Users.entity';
export class TourSourceHints {
[EntityRepositoryType]?: TourSourceHintsRepository;
[PrimaryKeyProp]?: 'place';
place?: Ref<TourSources> | null;
place_id?: number | null;
user!: Ref<Users>;
user_id!: number;
state?: TTourSourceHintsState | null;
changes?: string | null;
reason?: TTourSourceHintsReason | null;
external_updated?: string | null;
seen_hash?: string | null;
seen_updated?: string | null;
checked_at?: string | null;
}
export const TourSourceHintsState = {
POSSIBLE_CHANGE: 'possible_change',
CHANGED: 'changed',
UNVERIFIABLE: 'unverifiable',
} as const;
export type TTourSourceHintsState = (typeof TourSourceHintsState)[keyof typeof TourSourceHintsState];
export const TourSourceHintsReason = {
UNAVAILABLE: 'unavailable',
NO_CHANGE_TIME: 'no_change_time',
} as const;
export type TTourSourceHintsReason = (typeof TourSourceHintsReason)[keyof typeof TourSourceHintsReason];
export const TourSourceHintsSchema = defineEntity({
class: TourSourceHints,
repository: () => TourSourceHintsRepository,
properties: {
place: () => p.oneToOne(TourSources).primary().ref().name('place_id').deleteRule('cascade').nullable().hidden(),
place_id: p.integer().nullable().persist(false),
user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_tour_source_hints_user_id'),
user_id: p.integer().persist(false).index('idx_tour_source_hints_user_id'),
state: p.enum(() => TourSourceHintsState).nullable(),
changes: p.text().nullable(),
reason: p.enum(() => TourSourceHintsReason).nullable(),
external_updated: p.text().nullable(),
seen_hash: p.text().nullable(),
seen_updated: p.text().nullable(),
checked_at: p.text().nullable(),
},
});
@@ -1,5 +1,6 @@
import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { TourSourcesRepository } from '../repositories/TourSources.repository';
import { TourSourceHints } from './TourSourceHints.entity';
import { DbTimestampType } from '../types';
import { Tours } from './Tours.entity';
import { Users } from './Users.entity';
@@ -19,6 +20,7 @@ export class TourSources {
imported_at?: string | null;
imported_by?: number | null;
importedByRef?: Ref<Users> | null;
tour_source_hints: Ref<TourSourceHints> | null = null;
}
export const TourSourcesSchema = defineEntity({
@@ -43,5 +45,6 @@ export const TourSourcesSchema = defineEntity({
imported_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
imported_by: p.integer().nullable().persist(false).index('idx_tour_sources_imported_by'),
importedByRef: () => p.manyToOne(Users).ref().joinColumn('imported_by').nullable().hidden().index('idx_tour_sources_imported_by'),
tour_source_hints: () => p.oneToOne(TourSourceHints).ref().mappedBy('place').hidden(),
},
});
+3
View File
@@ -55,6 +55,7 @@ import { TodoItems } from './TodoItems.entity';
import { TourActivities } from './TourActivities.entity';
import { TourActivityExclusions } from './TourActivityExclusions.entity';
import { TourPackingLinks } from './TourPackingLinks.entity';
import { TourSourceHints } from './TourSourceHints.entity';
import { TourSources } from './TourSources.entity';
import { TrekPhotos } from './TrekPhotos.entity';
import { TripAlbumLinks } from './TripAlbumLinks.entity';
@@ -186,6 +187,7 @@ export class Users {
tour_activities_collection = new Collection<TourActivities>(this);
tour_activity_exclusions_collection = new Collection<TourActivityExclusions>(this);
tour_packing_links_collection = new Collection<TourPackingLinks>(this);
tour_source_hints_collection = new Collection<TourSourceHints>(this);
tour_sources_collection = new Collection<TourSources>(this);
trek_photos_collection = new Collection<TrekPhotos>(this);
trip_album_links_collection = new Collection<TripAlbumLinks>(this);
@@ -335,6 +337,7 @@ export const UsersSchema = defineEntity({
tour_activities_collection: () => p.oneToMany(TourActivities).mappedBy('assignedByRef').hidden(),
tour_activity_exclusions_collection: () => p.oneToMany(TourActivityExclusions).mappedBy('user').hidden(),
tour_packing_links_collection: () => p.oneToMany(TourPackingLinks).mappedBy('linkedByRef').hidden(),
tour_source_hints_collection: () => p.oneToMany(TourSourceHints).mappedBy('user').hidden(),
tour_sources_collection: () => p.oneToMany(TourSources).mappedBy('importedByRef').hidden(),
trek_photos_collection: () => p.oneToMany(TrekPhotos).mappedBy('owner').hidden(),
trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).mappedBy('user').hidden(),
@@ -16,6 +16,10 @@ export class WandererConnections {
last_check_error?: string | null;
created_at?: string | null;
updated_at?: string | null;
watch_enabled: number & Opt = 0;
watch_last_run_at?: string | null;
watch_failures: number & Opt = 0;
watch_paused_until?: string | null;
}
export const WandererConnectionsSchema = defineEntity({
@@ -32,5 +36,9 @@ export const WandererConnectionsSchema = defineEntity({
last_check_error: p.text().nullable(),
created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
watch_enabled: p.integer().default(0),
watch_last_run_at: p.text().nullable(),
watch_failures: p.integer().default(0),
watch_paused_until: p.text().nullable(),
},
});
+2
View File
@@ -113,6 +113,7 @@ import { TourActivityExclusionsSchema } from './TourActivityExclusions.entity';
import { TourChangeStepsSchema } from './TourChangeSteps.entity';
import { TourObstacleReviewsSchema } from './TourObstacleReviews.entity';
import { TourPackingLinksSchema } from './TourPackingLinks.entity';
import { TourSourceHintsSchema } from './TourSourceHints.entity';
import { TourSourcesSchema } from './TourSources.entity';
import { TourStationVisitsSchema } from './TourStationVisits.entity';
import { TourTypesSchema } from './TourTypes.entity';
@@ -263,6 +264,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
TourChangeStepsSchema,
TourObstacleReviewsSchema,
TourPackingLinksSchema,
TourSourceHintsSchema,
TourSourcesSchema,
TourStationVisitsSchema,
TourTypesSchema,
+13
View File
@@ -10,6 +10,7 @@ import {
paddleRegionGraphStatsSchema,
tourChangeSchema,
tourFixedLegSchema,
tourSourceHintChangeSchema,
tourWaypointSchema,
trackMarksSchema,
type Ferrata,
@@ -22,6 +23,7 @@ import {
type PaddleRegionGraphStats,
type TourChange,
type TourFixedLeg,
type TourSourceHintChange,
type TourWaypoint,
type TrackMarks,
} from '@trek/shared';
@@ -278,3 +280,14 @@ export const COLLECTION_TOUR_WAYPOINTS: JsonColumn<TourWaypoint[]> = {
schema: z.array(tourWaypointSchema),
fallback: () => [],
};
/**
* WAN-02: the parts of a trail the background watch found proven changed in
* its record, for one snapshot's hint. A broken row names none; the hint's
* state still says that something changed.
*/
export const TOUR_SOURCE_HINT_CHANGES: JsonColumn<TourSourceHintChange[]> = {
column: 'tour_source_hints.changes',
schema: z.array(tourSourceHintChangeSchema),
fallback: () => [],
};
@@ -0,0 +1,15 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface TourSourceHintsTable {
place_id: InsertOptional<number>;
user_id: number;
state: InsertOptional<string | null>;
changes: InsertOptional<string | null>;
reason: InsertOptional<string | null>;
external_updated: InsertOptional<string | null>;
seen_hash: InsertOptional<string | null>;
seen_updated: InsertOptional<string | null>;
checked_at: InsertOptional<string | null>;
}
@@ -12,4 +12,8 @@ export interface WandererConnectionsTable {
last_check_error: InsertOptional<string | null>;
created_at: InsertOptional<string | null>;
updated_at: InsertOptional<string | null>;
watch_enabled: InsertOptional<number>;
watch_last_run_at: InsertOptional<string | null>;
watch_failures: InsertOptional<number>;
watch_paused_until: InsertOptional<string | null>;
}
+2
View File
@@ -119,6 +119,7 @@ import type { TourActivityExclusionsTable } from './TourActivityExclusions.table
import type { TourChangeStepsTable } from './TourChangeSteps.table';
import type { TourObstacleReviewsTable } from './TourObstacleReviews.table';
import type { TourPackingLinksTable } from './TourPackingLinks.table';
import type { TourSourceHintsTable } from './TourSourceHints.table';
import type { TourSourcesTable } from './TourSources.table';
import type { TourStationVisitsTable } from './TourStationVisits.table';
import type { TourTypesTable } from './TourTypes.table';
@@ -275,6 +276,7 @@ export interface DB {
tour_change_steps: TourChangeStepsTable;
tour_obstacle_reviews: TourObstacleReviewsTable;
tour_packing_links: TourPackingLinksTable;
tour_source_hints: TourSourceHintsTable;
tour_sources: TourSourcesTable;
tour_station_visits: TourStationVisitsTable;
tour_types: TourTypesTable;
@@ -0,0 +1,43 @@
import { Migration } from '@mikro-orm/migrations';
/**
* WAN-02: a user may let TREK look now and then whether the wanderer trails
* they imported moved on since.
*
* `wanderer_connections` gets the switch, off for every existing connection,
* and what the background look needs to stay polite: when it last ran, how
* many runs in a row failed and until when it waits before it asks the
* instance again.
*
* `tour_source_hints` holds what a look found about one snapshot's source, for
* the user who imported it: the state (NULL while there is nothing to say,
* which is never a claim that nothing changed), the parts the trail's record
* proves changed, the reason a source could not be judged, the change time
* the trail said at the look, and what the user last saw of the trail in a
* manual check, which later looks compare against instead of the import. A
* hint never touches the tour; it goes with its snapshot and with its user.
*/
export class Migration20200101046120_a_wanderer_connection_can_watch_its_sources extends Migration {
override name = 'Migration20200101046120_a_wanderer_connection_can_watch_its_sources';
override up(): void {
this.addSql('ALTER TABLE wanderer_connections ADD COLUMN watch_enabled INTEGER NOT NULL DEFAULT 0');
this.addSql('ALTER TABLE wanderer_connections ADD COLUMN watch_last_run_at TEXT');
this.addSql('ALTER TABLE wanderer_connections ADD COLUMN watch_failures INTEGER NOT NULL DEFAULT 0');
this.addSql('ALTER TABLE wanderer_connections ADD COLUMN watch_paused_until TEXT');
this.addSql(`
CREATE TABLE IF NOT EXISTS tour_source_hints (
place_id INTEGER PRIMARY KEY REFERENCES tour_sources(place_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
state TEXT CHECK (state IS NULL OR state IN ('possible_change', 'changed', 'unverifiable')),
changes TEXT,
reason TEXT CHECK (reason IS NULL OR reason IN ('unavailable', 'no_change_time')),
external_updated TEXT,
seen_hash TEXT,
seen_updated TEXT,
checked_at TEXT
)
`);
this.addSql('CREATE INDEX IF NOT EXISTS idx_tour_source_hints_user_id ON tour_source_hints(user_id)');
}
}
@@ -0,0 +1,210 @@
import type {
TourSourceHints,
TTourSourceHintsReason,
TTourSourceHintsState,
} from '../entities/TourSourceHints.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
type HintsDB = Pick<
DB,
'tour_source_hints' | 'tour_sources' | 'places' | 'trips' | 'trip_members' | 'wanderer_connections'
>;
const PROVIDER = 'wanderer';
/** A snapshot the watch may look at, with what was last seen of its source. */
export interface WatchedSourceRow {
place_id: number;
external_id: string;
/** The content as the import delivered it, `name.description.route`. */
content_hash: string;
/** The trail's change time at the import. */
external_updated: string | null;
/** The content and change time the user last saw in a manual check; null before one. */
seen_hash: string | null;
seen_updated: string | null;
/** What the last look found; null when it found nothing to say or never looked. */
state: TTourSourceHintsState | null;
}
/** What a look found about one source, written for the user it belongs to. */
interface TourSourceHintWrite {
place_id: number;
user_id: number;
state: TTourSourceHintsState | null;
/** The changed parts as a JSON array, null when none. */
changes: string | null;
reason: TTourSourceHintsReason | null;
external_updated: string | null;
checked_at: string;
}
export interface TourSourceHintRow {
place_id: number;
state: TTourSourceHintsState;
changes: string | null;
reason: TTourSourceHintsReason | null;
external_updated: string | null;
checked_at: string | null;
}
export interface TourSourceHintListRow extends TourSourceHintRow {
trip_id: number;
trip_title: string;
tour_name: string;
}
/**
* `tour_source_hints` (WAN-02): what the background watch over a user's own
* wanderer snapshots found, one row per snapshot, for the user who imported
* it. Nothing here reads or writes a tour; a hint goes with its snapshot and
* with its user. Only snapshots in trips the user can still open count, and a
* hint shows only while the user's connection points at the instance it came
* from, so a removed connection leaves the rows unread rather than deleted.
*/
export class TourSourceHintsRepository extends TrekRepository<TourSourceHints> {
private db() {
return this.kysely<HintsDB>();
}
/**
* The user's snapshots of one instance that are due for a look: never looked
* at, or last looked at before `checkedBefore`. Longest waiting first, at
* most `limit`, so every run stays within its budget and all get their turn.
*/
async listDue(userId: number, instance: string, checkedBefore: string, limit: number): Promise<WatchedSourceRow[]> {
return await this.db()
.selectFrom('tour_sources as s')
.innerJoin('places as p', 'p.id', 's.place_id')
.leftJoin('tour_source_hints as h', 'h.place_id', 's.place_id')
.select([
's.place_id',
's.external_id',
's.content_hash',
's.external_updated',
'h.seen_hash',
'h.seen_updated',
'h.state',
])
.where('s.provider', '=', PROVIDER)
.where('s.instance', '=', instance)
.where('s.imported_by', '=', userId)
.where('p.trip_id', 'in', this.reachableTripIds(userId))
.where((eb) => eb.or([eb('h.checked_at', 'is', null), eb('h.checked_at', '<', checkedBefore)]))
.orderBy((eb) => eb.fn.coalesce('h.checked_at', eb.val('')))
.orderBy('s.place_id')
.limit(limit)
.execute()
.then((rows) => rows.map((row) => ({ ...row, state: (row.state as TTourSourceHintsState | null) ?? null })));
}
/** Writes what one look found; what the user last saw in a manual check stays as it is. */
async record(rows: readonly TourSourceHintWrite[]): Promise<void> {
if (rows.length === 0) return;
await this.db()
.insertInto('tour_source_hints')
.values([...rows])
.onConflict((oc) =>
oc.column('place_id').doUpdateSet((eb) => ({
state: eb.ref('excluded.state'),
changes: eb.ref('excluded.changes'),
reason: eb.ref('excluded.reason'),
external_updated: eb.ref('excluded.external_updated'),
checked_at: eb.ref('excluded.checked_at'),
})),
)
.execute();
}
/**
* A manual check read the source: its answer is what the user has seen now,
* so the open hint goes and later looks compare against this. Only for the
* user who imported the snapshot; anyone else's check leaves the row alone.
*/
async markSeen(
placeId: number,
userId: number,
seen: { contentHash: string; externalUpdated: string | null; checkedAt: string },
): Promise<void> {
const source = await this.db()
.selectFrom('tour_sources')
.select('imported_by')
.where('place_id', '=', placeId)
.where('provider', '=', PROVIDER)
.executeTakeFirst();
if (!source || source.imported_by !== userId) return;
await this.db()
.insertInto('tour_source_hints')
.values({
place_id: placeId,
user_id: userId,
state: null,
changes: null,
reason: null,
external_updated: seen.externalUpdated,
seen_hash: seen.contentHash,
seen_updated: seen.externalUpdated,
checked_at: seen.checkedAt,
})
.onConflict((oc) =>
oc.column('place_id').doUpdateSet((eb) => ({
state: null,
changes: null,
reason: null,
external_updated: eb.ref('excluded.external_updated'),
seen_hash: eb.ref('excluded.seen_hash'),
seen_updated: eb.ref('excluded.seen_updated'),
checked_at: eb.ref('excluded.checked_at'),
})),
)
.execute();
}
/** The user's open hints, newest look first. */
async listOpen(userId: number): Promise<TourSourceHintListRow[]> {
return await this.openHints(userId).orderBy('h.checked_at', 'desc').orderBy('h.place_id', 'desc').execute();
}
/** The user's open hint on one snapshot of a trip; undefined when there is none. */
async findOpen(userId: number, tripId: number, placeId: number): Promise<TourSourceHintListRow | undefined> {
return await this.openHints(userId)
.where('p.trip_id', '=', tripId)
.where('h.place_id', '=', placeId)
.executeTakeFirst();
}
private openHints(userId: number) {
return this.db()
.selectFrom('tour_source_hints as h')
.innerJoin('tour_sources as s', 's.place_id', 'h.place_id')
.innerJoin('places as p', 'p.id', 'h.place_id')
.innerJoin('trips as t', 't.id', 'p.trip_id')
.innerJoin('wanderer_connections as c', 'c.user_id', 'h.user_id')
.select([
'h.place_id',
'h.state',
'h.changes',
'h.reason',
'h.external_updated',
'h.checked_at',
'p.trip_id',
't.title as trip_title',
'p.name as tour_name',
])
.where('h.user_id', '=', userId)
.where('h.state', 'is not', null)
.whereRef('c.url', '=', 's.instance')
.where('p.trip_id', 'in', this.reachableTripIds(userId))
.$narrowType<{ state: TTourSourceHintsState; reason: TTourSourceHintsReason | null }>();
}
/** The trips the user can still open: their own and those they are a member of. */
private reachableTripIds(userId: number) {
return this.db()
.selectFrom('trips')
.select('id')
.where('user_id', '=', userId)
.union(this.db().selectFrom('trip_members').select('trip_id as id').where('user_id', '=', userId));
}
}
@@ -18,6 +18,14 @@ export interface WandererConnectionRow {
last_check_error: string | null;
}
/** WAN-02: the background watch of one connection, as stored. */
interface WandererWatchRow {
watch_enabled: number;
watch_last_run_at: string | null;
watch_failures: number;
watch_paused_until: string | null;
}
/**
* `wanderer_connections`: one row per user who connected an instance, the
* user id its primary key. Only the owner's own requests read or write it.
@@ -42,8 +50,9 @@ export class WandererConnectionsRepository extends TrekRepository<WandererConnec
/**
* Write the address and the TLS choice, and the token when one is given:
* `undefined` keeps the stored token, `null` drops it. A changed address
* clears what the last check said, since it said it about another server.
* `undefined` keeps the stored token, `null` drops it. A changed address or
* a new token clears what the last check said, since it said it about
* another server or another token (a refused token stops the watch, WAN-02).
*/
async save(
userId: number,
@@ -51,7 +60,9 @@ export class WandererConnectionsRepository extends TrekRepository<WandererConnec
): Promise<void> {
const platform = this.platform();
const tokenColumns = data.token !== undefined ? { token: data.token } : {};
const checkColumns = data.hostChanged ? { last_checked_at: null, last_check_ok: null, last_check_error: null } : {};
// What the last check said was said about another server, or about another token.
const freshCredentials = data.hostChanged || (data.token !== undefined && data.token !== null);
const checkColumns = freshCredentials ? { last_checked_at: null, last_check_ok: null, last_check_error: null } : {};
await this.db()
.insertInto('wanderer_connections')
.values({
@@ -81,6 +92,54 @@ export class WandererConnectionsRepository extends TrekRepository<WandererConnec
);
}
/** WAN-02: the watch of the user's connection; undefined without a connection. */
async findWatch(userId: number): Promise<WandererWatchRow | undefined> {
return await this.db()
.selectFrom('wanderer_connections')
.select(['watch_enabled', 'watch_last_run_at', 'watch_failures', 'watch_paused_until'])
.where('user_id', '=', userId)
.executeTakeFirst();
}
/** Switches the watch on or off. Switching it on starts afresh, without the pause an earlier failure left. */
async setWatch(userId: number, enabled: boolean): Promise<void> {
await this.nativeUpdate(
{ user: userId },
enabled ? { watch_enabled: 1, watch_failures: 0, watch_paused_until: null } : { watch_enabled: 0 },
);
}
/**
* The connections the watch may ask now: switched on, holding a token
* wanderer has not refused, and not waiting out a pause. The ones that
* waited longest come first, at most `limit` of them.
*/
async listWatchable(now: string, limit: number): Promise<number[]> {
const rows = await this.db()
.selectFrom('wanderer_connections')
.select('user_id')
.where('watch_enabled', '=', 1)
.where('token', 'is not', null)
.where((eb) => eb.or([eb('last_check_error', 'is', null), eb('last_check_error', '!=', 'unauthorized')]))
.where((eb) => eb.or([eb('watch_paused_until', 'is', null), eb('watch_paused_until', '<=', now)]))
.orderBy((eb) => eb.fn.coalesce('watch_last_run_at', eb.val('')))
.orderBy('user_id')
.limit(limit)
.execute();
return rows.map((row) => row.user_id);
}
/** How a run of the watch went: when it ended, how many failed in a row and until when to wait. */
async recordWatchRun(
userId: number,
run: { lastRunAt: string; failures: number; pausedUntil: string | null },
): Promise<void> {
await this.nativeUpdate(
{ user: userId },
{ watch_last_run_at: run.lastRunAt, watch_failures: run.failures, watch_paused_until: run.pausedUntil },
);
}
async remove(userId: number): Promise<void> {
await this.nativeDelete({ user: userId });
}
@@ -42,6 +42,7 @@ export type NotifEventType =
| 'replica_failure'
| 'paddle_region_requested'
| 'synology_session_cleared'
| 'wanderer_source_hint'
| 'plugin_notification';
/** Every event, in the order the preferences UI lists them. */
@@ -60,6 +61,7 @@ export const ALL_EVENT_TYPES: NotifEventType[] = [
'replica_failure',
'paddle_region_requested',
'synology_session_cleared',
'wanderer_source_hint',
'plugin_notification',
];
@@ -228,6 +228,20 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
navigateTextKey: 'notif.action.view_admin',
navigateTarget: () => '/admin?tab=addons',
},
// WAN-02: the wanderer watch found something about a snapshot's source; the
// text says which of the three it is, the button opens the tour in its trip.
wanderer_source_hint: {
inAppType: 'navigate',
titleKey: 'notif.wanderer_source_hint.title',
textKey: (p) =>
p.state === 'changed'
? 'notif.wanderer_source_hint.changed'
: p.state === 'unverifiable'
? 'notif.wanderer_source_hint.unverifiable'
: 'notif.wanderer_source_hint.possible_change',
navigateTextKey: 'notif.action.view_tour',
navigateTarget: (p) => (p.tripId && p.placeId ? `/trips/${p.tripId}?tab=plan&place=${p.placeId}` : null),
},
synology_session_cleared: {
inAppType: 'simple',
titleKey: 'notifications.synologySessionCleared.title',
@@ -35,6 +35,7 @@ const NTFY_EVENT_META: Partial<Record<NotifEventType, { priority: 1 | 2 | 3 | 4
replica_failure: { priority: 5, tags: ['warning', 'floppy_disk'] },
paddle_region_requested: { priority: 3, tags: ['canoe'] },
synology_session_cleared: { priority: 3, tags: ['warning'] },
wanderer_source_hint: { priority: 2, tags: ['hiking_boot'] },
};
const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] };
@@ -0,0 +1,6 @@
/** createZodDto wrapper over the @trek/shared contract of the wanderer watch switch (WAN-02). */
import { wandererWatchRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class WandererWatchDto extends createZodDto(wandererWatchRequestSchema) {}
@@ -9,9 +9,11 @@ import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.g
import { WandererEnabledGuard } from '../wanderer/wanderer-enabled.guard';
import { WandererImportDto, WandererSearchDto } from './dto/tours-wanderer.dto';
import { ToursWandererService } from './tours-wanderer.service';
import { WandererWatchService } from './wanderer-watch.service';
import { Body, Controller, Get, Headers, HttpCode, Param, Post, UseGuards } from '@nestjs/common';
import {
tourSourceCheckResponseSchema,
tourSourceHintResponseSchema,
wandererImportPreviewSchema,
wandererImportResponseSchema,
wandererSearchResponseSchema,
@@ -28,7 +30,10 @@ import {
@UseGuards(AddonGuard, JwtAuthGuard, WandererEnabledGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class ToursWandererController {
constructor(private readonly wanderer: ToursWandererService) {}
constructor(
private readonly wanderer: ToursWandererService,
private readonly watch: WandererWatchService,
) {}
@RequirePermission('place_edit')
@Post('search')
@@ -54,6 +59,17 @@ export class ToursWandererController {
return this.wanderer.checkSource(tripId, user.id, placeId);
}
/**
* WAN-02: the caller's own background hint on a wanderer snapshot, when
* their watch left one; it leads to the check above, so it asks the same right.
*/
@RequirePermission('place_edit')
@Get('sources/:placeId/hint')
@ResponseContract(tourSourceHintResponseSchema)
hint(@Param('tripId') tripId: string, @Param('placeId') placeId: string, @CurrentUser() user: User) {
return this.watch.hintFor(tripId, user.id, placeId);
}
@RequirePermission('place_edit')
@Post('import')
@ResponseContract(wandererImportResponseSchema)
@@ -13,6 +13,7 @@ import { PlacesService } from '../places/places.service';
import { WandererService } from '../wanderer/wanderer.service';
import { changedParts, computeTourMetrics, contentHashOf, localHashOf, parseRouteGeometry } from './tours.helpers';
import { ToursService } from './tours.service';
import { WandererWatchService } from './wanderer-watch.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, Logger } from '@nestjs/common';
import type {
@@ -64,6 +65,7 @@ export class ToursWandererService {
private readonly placeImport: PlaceImportService,
private readonly places: PlacesService,
private readonly tours: ToursService,
private readonly watch: WandererWatchService,
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
@InjectRepository(TourSources) private readonly sources: TourSourcesRepository,
@@ -136,13 +138,15 @@ export class ToursWandererService {
* MF-03: ask the source of one wanderer snapshot whether the trail changed
* since it came in, on the caller's own connection. Reads only: the
* snapshot stays as it is whatever the source says, and taking a change
* over is the confirmed reimport, with the hash this answer carries.
* over is the confirmed reimport, with the hash this answer carries. What
* the check read is what the caller has now seen of the trail, so their
* background hint on the snapshot goes (WAN-02).
*/
async checkSource(tripId: string, userId: number, placeId: string): Promise<TourSourceCheckResponse> {
const tid = tripRowId(tripId);
const pid = toRowId(placeId);
const row = pid === null ? undefined : await this.toursRepo.findProvenance(tid, pid);
if (!row) throw new DomainError(404, 'Tour not found');
if (!row || pid === null) throw new DomainError(404, 'Tour not found');
if (row.origin !== 'wanderer') {
throw new DomainError(400, 'This tour has no wanderer source', {
code: 'wanderer_no_source',
@@ -177,6 +181,7 @@ export class ToursWandererService {
}
const reimport = { trailId: externalId, contentHash: fetched.contentHash };
const externalUpdated = fetched.trail.updated ?? null;
await this.watch.noteSeen(pid, userId, { contentHash: fetched.contentHash, externalUpdated });
if (row.source_content_hash === null) return { ...answer('unknown'), externalUpdated, reimport };
const changes = changedParts(row.source_content_hash, fetched.contentHash);
return changes.length === 0
+17
View File
@@ -70,6 +70,23 @@ export function changedParts(before: string, now: string): Array<(typeof CONTENT
return CONTENT_PARTS.filter((_, i) => a[i] !== b[i]);
}
/**
* WAN-02: the parts of a content fingerprint a trail's record alone proves
* changed. The record carries the name and the description, never the line,
* so a changed route is never claimed here.
*/
export function recordChangedParts(
contentHash: string,
name: string,
description: string | null,
): Array<'name' | 'description'> {
const [nameHash, descriptionHash] = contentHash.split('.');
return [
...(fingerprint(name.trim()) === nameHash ? [] : (['name'] as const)),
...(fingerprint((description ?? '').trim()) === descriptionHash ? [] : (['description'] as const)),
];
}
/**
* At most `most` points of a line, evenly spread along it with its ends kept:
* a preview or a search gets the whole shape without the thousands of points.
+17 -1
View File
@@ -5,6 +5,7 @@ import { TourActivityExclusions } from '../../db/entities/TourActivityExclusions
import { TourChangeSteps } from '../../db/entities/TourChangeSteps.entity';
import { TourObstacleReviews } from '../../db/entities/TourObstacleReviews.entity';
import { TourPackingLinks } from '../../db/entities/TourPackingLinks.entity';
import { TourSourceHints } from '../../db/entities/TourSourceHints.entity';
import { TourSources } from '../../db/entities/TourSources.entity';
import { TourStationVisits } from '../../db/entities/TourStationVisits.entity';
import { TourTypes } from '../../db/entities/TourTypes.entity';
@@ -15,6 +16,7 @@ import { AssignmentsDomainModule } from '../assignments/assignments-domain.modul
import { CollectionsModule } from '../collections/collections.module';
import { MapsModule } from '../maps/maps.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { OverpassModule } from '../overpass/overpass.module';
import { PackingModule } from '../packing/packing.module';
import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module';
@@ -22,6 +24,7 @@ import { PermissionsModule } from '../permissions/permissions.module';
import { PlaceImportModule } from '../place-import/place-import.module';
import { PlacesModule } from '../places/places.module';
import { RoadtripModule } from '../roadtrip/roadtrip.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
import { WandererModule } from '../wanderer/wanderer.module';
import { CityWalkBriefingService } from './city-walk-briefing.service';
@@ -71,6 +74,9 @@ import { ToursWandererService } from './tours-wanderer.service';
import { ToursController } from './tours.controller';
import { ToursMcp } from './tours.mcp';
import { ToursService } from './tours.service';
import { WandererWatchController } from './wanderer-watch.controller';
import { WandererWatchJob } from './wanderer-watch.job';
import { WandererWatchService } from './wanderer-watch.service';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
@@ -97,7 +103,10 @@ import { Module } from '@nestjs/common';
* collection (LIB-03) are this domain's own rows; the collection's roles and
* its live notice to its members come from CollectionsService. A tour points at
* the packing list of its trip (P2D-XD-01) through a row of its own; the list
* and who sees which of its items stay PackingService's.
* and who sees which of its items stay PackingService's. The optional watch
* over a user's own wanderer snapshots (WAN-02) reads through WandererService,
* keeps its hints in this domain's own rows, tells the user through
* NotificationsService and runs hourly on SchedulingModule's registrar.
*/
@Module({
imports: [
@@ -125,12 +134,16 @@ import { Module } from '@nestjs/common';
CollectionsModule,
// A tour's packing link is read against the items Packing lets the user see (P2D-XD-01).
PackingModule,
// The wanderer watch tells the user of a new hint (WAN-02) and runs as a cron.
NotificationsModule,
SchedulingModule,
MikroOrmModule.forFeature([
CollectionTours,
Tours,
TourTypes,
TourWaypoints,
TourSources,
TourSourceHints,
TourObstacleReviews,
TourPackingLinks,
TourStationVisits,
@@ -158,6 +171,7 @@ import { Module } from '@nestjs/common';
TourLibraryController,
CollectionToursController,
TourPackingLinkController,
WandererWatchController,
],
providers: [
ToursService,
@@ -183,6 +197,8 @@ import { Module } from '@nestjs/common';
TourActivityStatsService,
CollectionToursService,
TourPackingLinkService,
WandererWatchService,
WandererWatchJob,
ToursMcp,
TourLibraryMcp,
TourWalkImportMcp,
@@ -0,0 +1,40 @@
import { ADDON_IDS } from '../../addons';
import type { User } from '../../types';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { getClientIp } from '../audit/client-ip';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { WandererEnabledGuard } from '../wanderer/wanderer-enabled.guard';
import { WandererWatchDto } from './dto/wanderer-watch.dto';
import { WandererWatchService } from './wanderer-watch.service';
import { Body, Controller, Get, Put, Req, UseGuards } from '@nestjs/common';
import { wandererWatchSchema } from '@trek/shared';
import type { Request } from 'express';
/**
* `/api/integrations/wanderer/watch` (WAN-02): the optional background watch
* over the caller's own wanderer snapshots, beside the connection it runs on.
* It reads and switches only the caller's own watch, and lists only the
* caller's own hints.
*/
@Controller('api/integrations/wanderer/watch')
@UseGuards(AddonGuard, JwtAuthGuard, WandererEnabledGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class WandererWatchController {
constructor(private readonly watch: WandererWatchService) {}
@Get()
@ResponseContract(wandererWatchSchema)
status(@CurrentUser() user: User) {
return this.watch.status(user.id);
}
@Put()
@ResponseContract(wandererWatchSchema)
setEnabled(@CurrentUser() user: User, @Body() body: WandererWatchDto, @Req() req: Request) {
return this.watch.setEnabled(user.id, body.enabled, getClientIp(req));
}
}
@@ -0,0 +1,74 @@
import type { WatchedSourceRow } from '../../db/repositories/TourSourceHints.repository';
import type { WandererTrailLook } from '../wanderer/wanderer.service';
import { recordChangedParts } from './tours.helpers';
import type { TourSourceHintChange, TourSourceHintReason, TourSourceHintState } from '@trek/shared';
/**
* WAN-02: the pure half of the background watch over a user's own wanderer
* snapshots: its budget and what a look at a trail's record says.
*
* The budget keeps a home instance unbothered. A run asks at most
* {@link WATCH_CONNECTIONS_PER_RUN} connections, each for at most
* {@link WATCH_SOURCES_PER_RUN} trail records one after another (plus the two
* small reads that name the account's own authors), and a source is looked at
* again no sooner than {@link WATCH_RECHECK_MS} later. A failing instance is
* left alone for longer after each failure in a row, up to
* {@link WATCH_BACKOFF_MAX_MS}; a rate limit or an outage is such a failure.
* Every read keeps the client's timeout and size cap.
*/
export const WATCH_CONNECTIONS_PER_RUN = 25;
export const WATCH_SOURCES_PER_RUN = 10;
export const WATCH_RECHECK_MS = 24 * 60 * 60 * 1000;
export const WATCH_BACKOFF_BASE_MS = 60 * 60 * 1000;
export const WATCH_BACKOFF_MAX_MS = 48 * 60 * 60 * 1000;
/** What a look found; a null state says nothing, least of all that nothing changed. */
export interface WatchVerdict {
state: TourSourceHintState | null;
changes: TourSourceHintChange[];
reason: TourSourceHintReason | null;
}
const NOTHING_TO_SAY: WatchVerdict = { state: null, changes: [], reason: null };
function unverifiable(reason: TourSourceHintReason): WatchVerdict {
return { state: 'unverifiable', changes: [], reason };
}
/** Two change times name the same moment, however the instance spelled them. */
function sameInstant(a: string, b: string): boolean {
if (a === b) return true;
const left = Date.parse(a);
return !Number.isNaN(left) && left === Date.parse(b);
}
/**
* What one look at a trail's record says about a snapshot. It compares with
* what the user last saw in a manual check, else with the import:
*
* - a name or description that differs is proven by the record: `changed`;
* - a change time that moved is only a suspicion: `possible_change`;
* - a trail that is gone or no longer the user's own, or a change time
* missing on either side, cannot be judged: `unverifiable`;
* - anything else says nothing. It never says "unchanged": the line may still
* differ, and only the manual check reads it.
*/
export function verdictOf(source: WatchedSourceRow, look: WandererTrailLook | undefined): WatchVerdict {
if (!look || look.kind === 'gone') return unverifiable('unavailable');
const reviewed = source.seen_hash !== null;
const changes = recordChangedParts(source.seen_hash ?? source.content_hash, look.name, look.description);
if (changes.length > 0) return { state: 'changed', changes, reason: null };
const baseline = reviewed ? source.seen_updated : source.external_updated;
if (baseline === null || look.updated === null) return unverifiable('no_change_time');
return sameInstant(baseline, look.updated) ? NOTHING_TO_SAY : { state: 'possible_change', changes: [], reason: null };
}
/** A hint the user has not been told about yet: something to say, and not what the last look said. */
export function isNewHint(source: WatchedSourceRow, verdict: WatchVerdict): boolean {
return verdict.state !== null && verdict.state !== source.state;
}
/** How long to leave an instance alone after this many failed runs in a row. */
export function backoffMs(failures: number): number {
return Math.min(WATCH_BACKOFF_BASE_MS * 2 ** Math.max(failures - 1, 0), WATCH_BACKOFF_MAX_MS);
}
@@ -0,0 +1,32 @@
import { logError } from '../audit/audit-log.logger';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { WandererWatchService } from './wanderer-watch.service';
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
/**
* WAN-02: the hourly run of the optional wanderer watch. Hourly is how often
* a connection may get its turn, not how often a trail is read: a source is
* looked at again only a day later, and a run with nothing due asks no
* instance anything. Whether wanderer is on, and which users switched the
* watch on, is read per run, so a change lands without a restart.
*/
@Injectable()
export class WandererWatchJob implements OnApplicationBootstrap {
constructor(
private readonly watch: WandererWatchService,
private readonly registrar: CronRegistrarService,
) {}
onApplicationBootstrap(): void {
if (!this.registrar.isEnabled()) return;
this.registrar.register('wanderer-watch', '23 * * * *', () => this.tick());
}
async tick(): Promise<void> {
try {
await this.watch.run();
} catch (err: unknown) {
logError(`wanderer watch: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
@@ -0,0 +1,219 @@
import { TourSourceHints } from '../../db/entities/TourSourceHints.entity';
import { TOUR_SOURCE_HINT_CHANGES } from '../../db/json-columns';
import type {
TourSourceHintListRow,
TourSourceHintRow,
TourSourceHintsRepository,
WatchedSourceRow,
} from '../../db/repositories/TourSourceHints.repository';
import { dbNow, utcSuffix } from '../../db/types';
import { decodeJson, encodeJson } from '../../utils/json-column';
import { AddonsService } from '../addons/addons.service';
import { logError } from '../audit/audit-log.logger';
import { toRowId } from '../common/row-id';
import { UnitOfWork } from '../database/unit-of-work';
import { NotificationsService } from '../notifications/notifications.service';
import { WandererService, type WandererTrailLook, type WandererWatchLooks } from '../wanderer/wanderer.service';
import {
backoffMs,
isNewHint,
verdictOf,
WATCH_CONNECTIONS_PER_RUN,
WATCH_RECHECK_MS,
WATCH_SOURCES_PER_RUN,
type WatchVerdict,
} from './wanderer-watch.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type { TourSourceHint, TourSourceHintResponse, WandererSourceHintItem, WandererWatch } from '@trek/shared';
/** A hint as it goes out: the stored changes read back, the look's time in ISO. */
function hintOf(row: TourSourceHintRow): TourSourceHint {
return {
state: row.state,
changes: decodeJson(TOUR_SOURCE_HINT_CHANGES, row.changes, `hint ${row.place_id}`),
reason: row.reason,
externalUpdated: row.external_updated,
checkedAt: utcSuffix(row.checked_at) ?? '',
};
}
function itemOf(row: TourSourceHintListRow): WandererSourceHintItem {
return {
...hintOf(row),
placeId: row.place_id,
tripId: row.trip_id,
tripTitle: row.trip_title,
tourName: row.tour_name,
};
}
/** A look that had nothing due: no trail asked, nothing read. */
const NOTHING_READ: WandererWatchLooks = { ok: true, looks: new Map() };
interface Finding {
source: WatchedSourceRow;
verdict: WatchVerdict;
}
/**
* WAN-02: the optional background watch over a user's own wanderer snapshots.
*
* Off until the user switches it on for their connection. A run looks, within
* the budget of `wanderer-watch.helpers.ts`, at the small records of the
* trails the user imported into trips they can still open, and writes per
* snapshot what the record suggests. New hints reach the user as a
* notification that leads to the tour, where the manual check (MF-03) reads
* the source in full and a confirmed reimport takes a change in.
*
* It never writes a tour, never downloads a GPX and never asks through anyone
* else's connection. A removed connection, the switch off, a refused token or
* wanderer switched off by the admin end the looks; the hints and snapshots
* already there stay.
*/
@Injectable()
export class WandererWatchService {
/** A run still going when the next is due is not started twice in this process. */
private running = false;
constructor(
private readonly addons: AddonsService,
private readonly wanderer: WandererService,
private readonly notifications: NotificationsService,
private readonly uow: UnitOfWork,
@InjectRepository(TourSourceHints) private readonly hints: TourSourceHintsRepository,
) {}
/** The cron's entry: every connection that may be asked now, one after another. */
async run(now: Date = new Date()): Promise<void> {
if (this.running || !(await this.addons.isWandererAvailable())) return;
this.running = true;
try {
const userIds = await this.wanderer.watchableUserIds(dbNow(now), WATCH_CONNECTIONS_PER_RUN);
await userIds.reduce(async (previous, userId) => {
await previous;
// One instance that fails must not keep the others from their turn.
await this.watchUser(userId, now).catch((err: unknown) => {
logError(`wanderer watch failed for user ${userId}: ${err instanceof Error ? err.message : String(err)}`);
});
}, Promise.resolve());
} finally {
this.running = false;
}
}
/**
* One user's due snapshots. An instance that fails is left alone for longer
* after each failure in a row; the hints already there stay as they are.
*/
private async watchUser(userId: number, now: Date): Promise<void> {
const instance = await this.wanderer.instanceOf(userId);
const checkedBefore = dbNow(new Date(now.getTime() - WATCH_RECHECK_MS));
const due = await this.hints.listDue(userId, instance, checkedBefore, WATCH_SOURCES_PER_RUN);
// Nothing due asks the instance nothing.
const ids = due.map((source) => source.external_id);
const read = ids.length > 0 ? await this.wanderer.lookAtTrails(userId, ids) : NOTHING_READ;
if (!read.ok) {
const failures = (await this.wanderer.watchState(userId)).failures + 1;
await this.wanderer.noteWatchRun(userId, {
lastRunAt: dbNow(now),
failures,
pausedUntil: dbNow(new Date(now.getTime() + backoffMs(failures))),
});
return;
}
const findings = due.map((source) => ({ source, verdict: verdictOf(source, read.looks.get(source.external_id)) }));
await this.uow.transactional(async () => {
await this.hints.record(
findings.map(({ source, verdict }) => ({
place_id: source.place_id,
user_id: userId,
state: verdict.state,
changes: verdict.changes.length > 0 ? encodeJson(TOUR_SOURCE_HINT_CHANGES, verdict.changes) : null,
reason: verdict.reason,
external_updated: lookedUpdated(read.looks.get(source.external_id)),
checked_at: dbNow(now),
})),
);
await this.wanderer.noteWatchRun(userId, { lastRunAt: dbNow(now), failures: 0, pausedUntil: null });
});
await this.announce(userId, findings.filter(({ source, verdict }) => isNewHint(source, verdict)));
}
/**
* Tell the user about the hints they have not heard of, one notification per
* tour so each leads to its own.
* @txStandalone the notices stand on their own; a failed one never undoes what the look wrote
*/
private async announce(userId: number, fresh: readonly Finding[]): Promise<void> {
if (fresh.length === 0) return;
const rows = new Map((await this.hints.listOpen(userId)).map((row) => [row.place_id, row]));
await fresh.reduce(async (previous, { source, verdict }) => {
await previous;
const row = rows.get(source.place_id);
if (!row || !verdict.state) return;
await this.notifications
.send({
event: 'wanderer_source_hint',
actorId: null,
scope: 'user',
targetId: userId,
params: {
state: verdict.state,
tour: row.tour_name,
trip: row.trip_title,
tripId: String(row.trip_id),
placeId: String(row.place_id),
},
})
.catch((err: unknown) => {
logError(`wanderer watch notice for ${source.place_id}: ${err instanceof Error ? err.message : String(err)}`);
});
}, Promise.resolve());
}
/** The watch of the user's connection, with the open hints. */
async status(userId: number): Promise<WandererWatch> {
const state = await this.wanderer.watchState(userId);
return {
enabled: state.enabled,
connected: state.connected,
lastRunAt: utcSuffix(state.lastRunAt),
pausedUntil: utcSuffix(state.pausedUntil),
blocked: state.blocked,
hints: state.connected ? (await this.hints.listOpen(userId)).map(itemOf) : [],
};
}
/** Switch the watch on or off; switching it off ends further looks at once. */
async setEnabled(userId: number, enabled: boolean, clientIp: string | null): Promise<WandererWatch> {
await this.wanderer.setWatch(userId, enabled, clientIp);
return await this.status(userId);
}
/** The caller's own open hint on one snapshot of the trip; null when there is none. */
async hintFor(tripId: string, userId: number, placeId: string): Promise<TourSourceHintResponse> {
const tid = toRowId(tripId);
const pid = toRowId(placeId);
const row = tid === null || pid === null ? undefined : await this.hints.findOpen(userId, tid, pid);
return { hint: row ? hintOf(row) : null };
}
/**
* A manual check read the source in full: what it found is what the user has
* seen now, so their open hint goes and later looks compare with this.
* @txStandalone the note of what the user saw stands on its own; the check around it writes nothing else
*/
async noteSeen(
placeId: number,
userId: number,
seen: { contentHash: string; externalUpdated: string | null },
): Promise<void> {
await this.hints.markSeen(placeId, userId, { ...seen, checkedAt: dbNow() });
}
}
/** The change time a look read, kept for the hint to show; none when the trail could not be read. */
function lookedUpdated(look: WandererTrailLook | undefined): string | null {
return look?.kind === 'record' ? look.updated : null;
}
+125 -6
View File
@@ -43,6 +43,34 @@ export interface WandererTrailDownload {
gpx: string;
}
/**
* WAN-02: a trail as the background watch sees it, from its small record
* alone: its texts and its change time, or `gone` when it was deleted, is
* refused or is no longer the user's own.
*/
export type WandererTrailLook =
| { kind: 'record'; name: string; description: string | null; updated: string | null }
| { kind: 'gone' };
/**
* WAN-02: what one look of the watch read: a look per trail, or why the
* instance itself could not be asked, for the watch to back off from.
*/
export type WandererWatchLooks =
| { ok: true; looks: Map<string, WandererTrailLook> }
| { ok: false; code: WandererErrorCode };
/** WAN-02: the background watch of one user's connection. */
interface WandererWatchState {
connected: boolean;
enabled: boolean;
lastRunAt: string | null;
failures: number;
pausedUntil: string | null;
/** wanderer refused the token; the watch asks nothing until a test passes or a new token is saved. */
blocked: boolean;
}
/** An upstream failure as the API answers it: never a 401, which the app reads as its own session ending. */
function refusalOf(err: WandererError): DomainError {
const status = err.code === 'not_found' ? 404 : err.code === 'forbidden' || err.code === 'not_own_trail' ? 403 : 502;
@@ -145,12 +173,7 @@ export class WandererService {
private async credentials(userId: number): Promise<WandererCreds> {
const row = await this.connections.findRow(userId);
const token = row?.token ? decrypt_api_key(row.token) : null;
if (!row?.url || !token) {
throw new DomainError(409, 'wanderer is not connected', {
code: 'wanderer_not_connected',
details: { code: 'not_connected' },
});
}
if (!row?.url || !token) throw notConnected();
return { baseUrl: row.url, token, allowInsecureTls: !!row.allow_insecure_tls };
}
@@ -216,6 +239,95 @@ export class WandererService {
}
}
/** WAN-02: whether the user's connection is watched in the background, and how its last run went. */
async watchState(userId: number): Promise<WandererWatchState> {
const row = await this.connections.findRow(userId);
const watch = await this.connections.findWatch(userId);
return {
connected: !!(row?.url && row.token),
enabled: watch?.watch_enabled === 1,
lastRunAt: watch?.watch_last_run_at ?? null,
failures: watch?.watch_failures ?? 0,
pausedUntil: watch?.watch_paused_until ?? null,
blocked: row?.last_check_ok === 0 && row.last_check_error === 'unauthorized',
};
}
/**
* WAN-02: switch the background watch on or off. It only ever acts on the
* user's own connection, so there has to be one to switch it on for.
*/
async setWatch(userId: number, enabled: boolean, clientIp: string | null): Promise<void> {
if (enabled && !(await this.watchState(userId)).connected) throw notConnected();
await this.connections.setWatch(userId, enabled);
await this.audit.writeAudit({
userId,
action: enabled ? 'wanderer.watch_enabled' : 'wanderer.watch_disabled',
ip: clientIp,
details: {},
});
}
/** WAN-02: the users whose connection the watch may ask now, longest waiting first. */
async watchableUserIds(now: string, limit: number): Promise<number[]> {
return await this.connections.listWatchable(now, limit);
}
/**
* WAN-02: note how a run of the watch went on the connection.
* @txStandalone the run's bookkeeping stands on its own: it is true whatever the hints written beside it say
*/
async noteWatchRun(
userId: number,
run: { lastRunAt: string; failures: number; pausedUntil: string | null },
): Promise<void> {
await this.connections.recordWatchRun(userId, run);
}
/**
* WAN-02: the records of the given trails, for the background watch. One
* small read per trail, one after another, and never its GPX: the change
* time and the texts are what a look can afford. A trail that is gone,
* refused or someone else's reads as `gone`; a failure of the instance
* itself (unreachable, an error, a refused token, which is noted on the
* connection) ends the whole look with its reason.
*/
async lookAtTrails(userId: number, trailIds: readonly string[]): Promise<WandererWatchLooks> {
const creds = await this.credentials(userId);
const looks = new Map<string, WandererTrailLook>();
try {
const authors = await this.client.ownActors(creds);
await [...new Set(trailIds)].reduce(async (previous, id) => {
await previous;
looks.set(id, await this.lookAt(creds, id, authors));
}, Promise.resolve());
} catch (err: unknown) {
if (!(err instanceof WandererError)) throw err;
if (err.code === 'unauthorized') await this.noteCheck(userId, false, err.code);
return { ok: false, code: err.code };
}
return { ok: true, looks };
}
private async lookAt(creds: WandererCreds, id: string, authors: readonly string[]): Promise<WandererTrailLook> {
try {
const trail = await this.client.trail(creds, id);
if (!trail.author || !authors.includes(trail.author)) return { kind: 'gone' };
return {
kind: 'record',
name: trail.name,
description: trail.description ?? null,
updated: trail.updated ?? null,
};
} catch (err: unknown) {
// Deleted or refused: the trail is out of reach, not the instance.
if (err instanceof WandererError && (err.code === 'not_found' || err.code === 'forbidden')) {
return { kind: 'gone' };
}
throw err;
}
}
/** The list row of a trail by its id, as the search would show it. */
async findTrail(userId: number, trailId: string): Promise<WandererTrailFound | null> {
const creds = await this.credentials(userId);
@@ -229,6 +341,13 @@ export class WandererService {
}
}
function notConnected(): DomainError {
return new DomainError(409, 'wanderer is not connected', {
code: 'wanderer_not_connected',
details: { code: 'not_connected' },
});
}
function summaryOf(instance: string, hit: WandererSearchHit): WandererTrailFound {
const author = hit.author_name ? (hit.domain ? `${hit.author_name}@${hit.domain}` : hit.author_name) : null;
return {
+73 -1
View File
@@ -1,5 +1,6 @@
/**
* wanderer e2e: the personal connection (/api/integrations/wanderer) and the
* wanderer e2e: the personal connection (/api/integrations/wanderer), its
* optional background watch (/api/integrations/wanderer/watch, WAN-02) and the
* trip-side search, preview and import (/api/trips/:tripId/tours/wanderer)
* through the real JwtAuthGuard, AddonGuard, WandererEnabledGuard and
* TripAccessGuard, over the real services and repositories on a migrated temp
@@ -16,11 +17,13 @@ import { Trips } from '../../src/db/entities/Trips.entity';
import { WandererConnections } from '../../src/db/entities/WandererConnections.entity';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { withRequestContext } from '../../src/nest/database/request-context';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { ToursModule } from '../../src/nest/tours/tours.module';
import { WandererWatchService } from '../../src/nest/tours/wanderer-watch.service';
import { WandererClient, WandererError, type WandererSearchHit } from '../../src/nest/wanderer/wanderer.client';
import { WandererModule } from '../../src/nest/wanderer/wanderer.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
@@ -32,6 +35,7 @@ import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
@@ -533,4 +537,72 @@ describe('wanderer e2e', () => {
expect(detail.body.provenance.source.importedAt).toEqual(expect.any(String));
});
});
describe('the background watch over the caller own snapshots (WAN-02)', () => {
const watchRoute = (userId = OWNER) => request(server).get('/api/integrations/wanderer/watch').set(as(userId));
const switchWatch = (enabled: unknown, userId = OWNER) =>
request(server).put('/api/integrations/wanderer/watch').set(as(userId)).send({ enabled });
const hint = (placeId: number, userId = OWNER) =>
request(server).get(`/api/trips/5/tours/wanderer/sources/${placeId}/hint`).set(as(userId));
const imported = async () => {
await connect();
const res = await importTrail((await preview()).body.contentHash);
return res.body.tour.place_id as number;
};
it('is off until switched on, and only for a connection that exists', async () => {
expect((await watchRoute()).body).toEqual({
enabled: false,
connected: false,
lastRunAt: null,
pausedUntil: null,
blocked: false,
hints: [],
});
const refused = await switchWatch(true);
expect(refused.status).toBe(409);
expect(refused.body).toMatchObject({ code: 'not_connected' });
expect((await switchWatch('yes')).status).toBe(400);
await connect();
expect((await switchWatch(true)).body).toMatchObject({ enabled: true, connected: true });
expect((await switchWatch(false)).body).toMatchObject({ enabled: false });
});
it('a moved trail leaves a hint for its importer, the manual check clears it, and no tour changes', async () => {
const placeId = await imported();
await switchWatch(true);
client.trail.mockResolvedValue({ ...RECORD, updated: '2026-10-14' });
const before = await countRows(orm, Places);
const gpxReads = client.gpx.mock.calls.length;
// The cron's tick, in the request context the registrar gives every tick.
await withRequestContext(app.get(MikroORM), () => app.get(WandererWatchService).run());
expect(client.gpx).toHaveBeenCalledTimes(gpxReads);
expect(await countRows(orm, Places)).toBe(before);
expect((await watchRoute()).body.hints).toEqual([
expect.objectContaining({ placeId, tripId: 5, tripTitle: 'Alps', state: 'possible_change' }),
]);
expect((await hint(placeId)).body).toEqual({ hint: expect.objectContaining({ state: 'possible_change' }) });
const checked = await request(server).post(`/api/trips/5/tours/wanderer/sources/${placeId}/check`).set(as(OWNER));
expect(checked.body).toMatchObject({ state: 'unchanged' });
expect((await hint(placeId)).body).toEqual({ hint: null });
expect((await watchRoute()).body.hints).toEqual([]);
});
it('the hint of a tour is the caller own and asks the right of the check', async () => {
const placeId = await imported();
expect((await hint(placeId, VIEWER)).status).toBe(403);
expect((await hint(placeId, OUTSIDER)).status).toBe(404);
checkPermission.mockReturnValue(true);
expect((await hint(placeId, VIEWER)).body).toEqual({ hint: null });
});
it('404 while the admin has not allowed wanderer under Tours', async () => {
await setAppSetting(orm, 'tours_wanderer_enabled', 'false');
expect((await watchRoute()).status).toBe(404);
expect((await switchWatch(true)).status).toBe(404);
});
});
});
+27
View File
@@ -1,5 +1,6 @@
import { TourActivities } from '../../../src/db/entities/TourActivities.entity';
import { TourPackingLinks } from '../../../src/db/entities/TourPackingLinks.entity';
import { TourSources } from '../../../src/db/entities/TourSources.entity';
import { TourStationVisits, type TTourStationVisitsStatus } from '../../../src/db/entities/TourStationVisits.entity';
import { TourWaypoints, type TTourWaypointsImportance } from '../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
@@ -97,3 +98,29 @@ export async function linkTourPacking(
): Promise<void> {
await insertRow(orm, TourPackingLinks, { place: placeId, category, linkedByRef: linkedBy });
}
/**
* RS-05: the import record of a tour copied in from wanderer, as the import
* writes it; the content and local fingerprints default to placeholders.
*/
export async function makeTourSource(
orm: FactoryOrm,
placeId: number,
importedBy: number | null,
overrides: EntityData<TourSources> = {},
): Promise<void> {
await insertRow(
orm,
TourSources,
{
place: placeId,
provider: 'wanderer',
instance: 'https://wanderer.example.org',
external_id: 'trail1',
content_hash: 'n.d.r',
local_hash: 'n.r',
importedByRef: importedBy,
},
overrides,
);
}
+4 -3
View File
@@ -45,14 +45,15 @@ describe('ALL_ENTITIES', () => {
* + tour_activities, the recorded tracks assigned to a tour
* + tour_activity_exclusions, the tours a user left out of their own activity sums
* + collection_tours, the whole tours a collection keeps
* + tour_packing_links, the packing list or category a tour points at)
* + tour_packing_links, the packing list or category a tour points at
* + tour_source_hints, what the wanderer watch found about a snapshot's source)
* is registered, and no two share a class name (a silent duplicate would
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
* which de-duplicates by class name).
*/
it('ENT-003: exactly 148 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(148);
it('ENT-003: exactly 149 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(149);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -42,7 +42,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// + tour_activity_exclusions: id, user_id, place_id (an exclusion always names its user and its tour).
// + collection_tours: id, collection_id, name, waypoints, tour_type, origin, source_trip_title, source_tour_name
// (a kept tour is only ever written whole, from the tour it was saved from).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 518;
// + tour_source_hints: user_id (a hint is only ever written for the user whose snapshot it is about).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 519;
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
@@ -78,6 +78,7 @@ const UNNUMBERED = [
'Migration20200101046010_a_day_can_hold_one_stage_of_a_tour',
'Migration20200101046100_a_collection_keeps_whole_tours',
'Migration20200101046110_a_tour_points_at_its_packing_list',
'Migration20200101046120_a_wanderer_connection_can_watch_its_sources',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -0,0 +1,197 @@
/**
* `TourSourceHintsRepository` (WAN-02): what the background watch over a
* user's own wanderer snapshots found, and which snapshots it may look at.
* Seeded through the ORM factories over the migrated snapshot schema.
*/
import { TourSourceHints } from '../../../../src/db/entities/TourSourceHints.entity';
import { TripMembers } from '../../../../src/db/entities/TripMembers.entity';
import { WandererConnections } from '../../../../src/db/entities/WandererConnections.entity';
import type { TourSourceHintsRepository } from '../../../../src/db/repositories/TourSourceHints.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { makePlace } from '../../../helpers/factories/places';
import { deleteRows, findRow, insertRow, updateRows } from '../../../helpers/factories/rows';
import { makeTour, makeTourSource } from '../../../helpers/factories/tours';
import { addTripMember, makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const INSTANCE = 'https://wanderer.example.org';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: TourSourceHintsRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(TourSourceHints);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
/** A wanderer snapshot in the trip, imported by `importedBy`. */
async function snapshot(tripId: number, importedBy: number | null, externalId: string, instance = INSTANCE) {
const place = await makePlace(t, tripId, { name: `Trail ${externalId}` });
await makeTour(t, place.id, { origin: 'wanderer' });
await makeTourSource(t, place.id, importedBy, { external_id: externalId, instance, external_updated: '2026-10-01' });
return place.id;
}
async function connect(userId: number, url = INSTANCE) {
await insertRow(t, WandererConnections, { user: userId, url, token: 'enc' });
}
const look = (placeId: number, userId: number, checkedAt: string, state: 'possible_change' | null = null) => ({
place_id: placeId,
user_id: userId,
state,
changes: null,
reason: null,
external_updated: '2026-10-02',
checked_at: checkedAt,
});
describe('TourSourceHintsRepository', () => {
it('THREPO-001: lists only the user own imports of the instance, in trips they can still open', async () => {
const { user } = await makeUser(t);
const { user: other } = await makeUser(t);
const own = await makeTrip(t, user.id, { title: 'Alps' });
const shared = await makeTrip(t, other.id, { title: 'Shared' });
await addTripMember(t, shared.id, user.id);
const left = await makeTrip(t, other.id, { title: 'Left' });
const mine = await snapshot(own.id, user.id, 'a');
const inShared = await snapshot(shared.id, user.id, 'b');
await snapshot(own.id, other.id, 'c');
await snapshot(own.id, user.id, 'd', 'https://elsewhere.example');
await snapshot(left.id, user.id, 'e');
const due = await repo.listDue(user.id, INSTANCE, '2026-10-20 00:00:00', 10);
expect(due.map((row) => row.place_id).sort()).toEqual([mine, inShared].sort());
expect(due[0]).toMatchObject({
content_hash: 'n.d.r',
external_updated: '2026-10-01',
seen_hash: null,
state: null,
});
});
it('THREPO-002: never looked at first, then the longest waiting, within the cutoff and the limit', async () => {
const { user } = await makeUser(t);
const trip = await makeTrip(t, user.id);
const fresh = await snapshot(trip.id, user.id, 'a');
const old = await snapshot(trip.id, user.id, 'b');
const older = await snapshot(trip.id, user.id, 'c');
const recent = await snapshot(trip.id, user.id, 'd');
await repo.record([
look(old, user.id, '2026-10-18 10:00:00'),
look(older, user.id, '2026-10-17 10:00:00'),
look(recent, user.id, '2026-10-19 12:00:00'),
]);
const due = await repo.listDue(user.id, INSTANCE, '2026-10-19 00:00:00', 10);
expect(due.map((row) => row.place_id)).toEqual([fresh, older, old]);
expect((await repo.listDue(user.id, INSTANCE, '2026-10-19 00:00:00', 2)).map((row) => row.place_id)).toEqual([
fresh,
older,
]);
});
it('THREPO-003: a look writes its finding and keeps what the user last saw', async () => {
const { user } = await makeUser(t);
const trip = await makeTrip(t, user.id);
const placeId = await snapshot(trip.id, user.id, 'a');
await repo.markSeen(placeId, user.id, {
contentHash: 'seen.hash.now',
externalUpdated: '2026-10-05',
checkedAt: '2026-10-05 08:00:00',
});
await repo.record([{ ...look(placeId, user.id, '2026-10-19 08:00:00', 'possible_change'), changes: '["name"]' }]);
expect(await findRow(t, TourSourceHints, { place: placeId })).toMatchObject({
state: 'possible_change',
changes: '["name"]',
external_updated: '2026-10-02',
seen_hash: 'seen.hash.now',
seen_updated: '2026-10-05',
checked_at: '2026-10-19 08:00:00',
});
await repo.record([]);
});
it('THREPO-004: a manual check clears the hint for the importer only, and starts a row when there was none', async () => {
const { user } = await makeUser(t);
const { user: member } = await makeUser(t);
const trip = await makeTrip(t, user.id);
const placeId = await snapshot(trip.id, user.id, 'a');
const seen = { contentHash: 'x.y.z', externalUpdated: '2026-10-10', checkedAt: '2026-10-10 09:00:00' };
await repo.markSeen(placeId, member.id, seen);
expect(await findRow(t, TourSourceHints, { place: placeId })).toBeNull();
await repo.markSeen(placeId, user.id, seen);
expect(await findRow(t, TourSourceHints, { place: placeId })).toMatchObject({ state: null, seen_hash: 'x.y.z' });
await repo.record([look(placeId, user.id, '2026-10-19 08:00:00', 'possible_change')]);
await repo.markSeen(placeId, user.id, { ...seen, contentHash: 'x.y.w', externalUpdated: null });
expect(await findRow(t, TourSourceHints, { place: placeId })).toMatchObject({
state: null,
changes: null,
seen_hash: 'x.y.w',
seen_updated: null,
});
await repo.markSeen(999_999, user.id, seen);
});
it('THREPO-005: open hints show while the connection points at their instance, newest look first', async () => {
const { user } = await makeUser(t);
const trip = await makeTrip(t, user.id, { title: 'Alps' });
const first = await snapshot(trip.id, user.id, 'a');
const second = await snapshot(trip.id, user.id, 'b');
const quiet = await snapshot(trip.id, user.id, 'c');
await repo.record([
look(first, user.id, '2026-10-18 08:00:00', 'possible_change'),
look(second, user.id, '2026-10-19 08:00:00', 'possible_change'),
look(quiet, user.id, '2026-10-19 09:00:00'),
]);
expect(await repo.listOpen(user.id)).toEqual([]);
await connect(user.id);
const open = await repo.listOpen(user.id);
expect(open.map((row) => row.place_id)).toEqual([second, first]);
expect(open[0]).toMatchObject({
trip_id: trip.id,
trip_title: 'Alps',
tour_name: 'Trail b',
state: 'possible_change',
});
expect(await repo.findOpen(user.id, trip.id, first)).toMatchObject({ place_id: first });
expect(await repo.findOpen(user.id, trip.id, quiet)).toBeUndefined();
expect(await repo.findOpen(user.id, trip.id + 1, first)).toBeUndefined();
});
it('THREPO-006: a connection to another instance, or a trip the user left, hides the hint', async () => {
const { user } = await makeUser(t);
const { user: owner } = await makeUser(t);
const trip = await makeTrip(t, owner.id);
await addTripMember(t, trip.id, user.id);
const placeId = await snapshot(trip.id, user.id, 'a');
await repo.record([look(placeId, user.id, '2026-10-19 08:00:00', 'possible_change')]);
await connect(user.id, 'https://elsewhere.example');
expect(await repo.listOpen(user.id)).toEqual([]);
await updateRows(t, WandererConnections, { user: user.id }, { url: INSTANCE });
expect(await repo.listOpen(user.id)).toHaveLength(1);
await deleteRows(t, TripMembers, { trip: trip.id, user: user.id });
expect(await repo.listOpen(user.id)).toEqual([]);
expect(await repo.listDue(user.id, INSTANCE, '2026-10-20 00:00:00', 10)).toEqual([]);
});
});
@@ -92,6 +92,68 @@ describe('WandererConnectionsRepository', () => {
expect(await connections.findRow(user.id)).toBeNull();
expect(await connections.findRow(other.id)).not.toBeNull();
});
const connect = (id: number) =>
connections.save(id, { url: 'https://a.example', allowInsecureTls: false, token: 'enc', hostChanged: false });
it('WANDREPO-004: a new token forgets what the last check said about the old one', async () => {
const { user } = createUser(testDb);
await connect(user.id);
await connections.recordCheck(user.id, false, 'unauthorized');
await connect(user.id);
expect(await connections.findRow(user.id)).toMatchObject({ last_check_ok: null, last_check_error: null });
});
it('WANDREPO-005: the watch is off by default, and switching it on starts without an old pause', async () => {
const { user } = createUser(testDb);
expect(await connections.findWatch(user.id)).toBeUndefined();
await connect(user.id);
expect(await connections.findWatch(user.id)).toEqual({
watch_enabled: 0,
watch_last_run_at: null,
watch_failures: 0,
watch_paused_until: null,
});
await connections.recordWatchRun(user.id, {
lastRunAt: '2026-10-19 10:00:00',
failures: 2,
pausedUntil: '2026-10-19 12:00:00',
});
await connections.setWatch(user.id, true);
expect(await connections.findWatch(user.id)).toEqual({
watch_enabled: 1,
watch_last_run_at: '2026-10-19 10:00:00',
watch_failures: 0,
watch_paused_until: null,
});
await connections.setWatch(user.id, false);
expect((await connections.findWatch(user.id))?.watch_enabled).toBe(0);
});
it('WANDREPO-006: only switched-on connections with an accepted token and no pause are watchable, longest waiting first', async () => {
const ids = Array.from({ length: 6 }, () => createUser(testDb).user.id);
await Promise.all(
ids.map(async (id) => {
await connect(id);
await connections.setWatch(id, true);
}),
);
const [never, ranEarly, ranLate, off, refused, paused] = ids;
await connections.recordWatchRun(ranEarly, { lastRunAt: '2026-10-19 08:00:00', failures: 0, pausedUntil: null });
await connections.recordWatchRun(ranLate, { lastRunAt: '2026-10-19 09:00:00', failures: 0, pausedUntil: null });
await connections.setWatch(off, false);
await connections.recordCheck(refused, false, 'unauthorized');
await connections.recordWatchRun(paused, {
lastRunAt: '2026-10-19 07:00:00',
failures: 1,
pausedUntil: '2026-10-19 11:00:00',
});
expect(await connections.listWatchable('2026-10-19 10:00:00', 10)).toEqual([never, ranEarly, ranLate]);
expect(await connections.listWatchable('2026-10-19 12:00:00', 10)).toEqual([never, paused, ranEarly, ranLate]);
expect(await connections.listWatchable('2026-10-19 12:00:00', 2)).toEqual([never, paused]);
});
});
describe('TourSourcesRepository', () => {
@@ -151,14 +151,15 @@ describe('getPreferencesMatrix', () => {
const { event_types } = await getPreferencesMatrix(user.id, 'user');
expect(event_types).not.toContain('version_available');
// +1 for plugin_notification: users can mute host-mediated plugin notifications.
expect(event_types.length).toBe(12);
// +1 for wanderer_source_hint: the hints of the user's own wanderer watch (WAN-02).
expect(event_types.length).toBe(13);
});
it('NPREF-005 — user scope excludes version_available for everyone including admins', async () => {
const { user } = createAdmin(testDb);
const { event_types } = await getPreferencesMatrix(user.id, 'admin', 'user');
expect(event_types).not.toContain('version_available');
expect(event_types.length).toBe(12);
expect(event_types.length).toBe(13);
});
it('NPREF-005b — admin scope returns the admin-scoped events', async () => {
@@ -580,6 +580,34 @@ describe('send() — in-app notification content', () => {
expect(notifs.length).toBe(1);
expect(notifs[0].text_key).toBe('notif.replica_failure.text');
});
it('NOTIF-WAN-001 a wanderer hint names its state in the text and leads to its tour (WAN-02)', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none');
const hint = (state: string, placeId: string) =>
send({
event: 'wanderer_source_hint',
actorId: null,
scope: 'user',
targetId: user.id,
params: { state, tour: 'Ridge', trip: 'Alps', tripId: '5', placeId },
});
await hint('possible_change', '11');
await hint('changed', '12');
await hint('unverifiable', '13');
await hint('changed', '');
const notifs = await getInAppNotifications(user.id);
expect(notifs.map((n) => [n.text_key, n.navigate_target])).toEqual(
expect.arrayContaining([
['notif.wanderer_source_hint.possible_change', '/trips/5?tab=plan&place=11'],
['notif.wanderer_source_hint.changed', '/trips/5?tab=plan&place=12'],
['notif.wanderer_source_hint.unverifiable', '/trips/5?tab=plan&place=13'],
['notif.wanderer_source_hint.changed', null],
]),
);
expect(notifs.every((n) => n.title_key === 'notif.wanderer_source_hint.title')).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,79 @@
/**
* WandererWatchController (WAN-02) and the hint route of
* ToursWandererController: their route metadata and their delegation to
* WandererWatchService. The watch has no MCP tool, as no part of the wanderer
* connection has one: it runs on a personal credential that never leaves the
* server. The hint of a tour asks 'place_edit', the right of the check it
* leads to.
*/
import 'reflect-metadata';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
import { ToursWandererController } from '../../../src/nest/tours/tours-wanderer.controller';
import type { ToursWandererService } from '../../../src/nest/tours/tours-wanderer.service';
import { WandererWatchController } from '../../../src/nest/tours/wanderer-watch.controller';
import type { WandererWatchService } from '../../../src/nest/tours/wanderer-watch.service';
import { WandererEnabledGuard } from '../../../src/nest/wanderer/wanderer-enabled.guard';
import type { User } from '../../../src/types';
import { RequestMethod } from '@nestjs/common';
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import type { Request } from 'express';
import { describe, expect, it, vi } from 'vitest';
const STATUS = { enabled: true, connected: true, lastRunAt: null, pausedUntil: null, blocked: false, hints: [] };
function watchService() {
return {
status: vi.fn(async () => STATUS),
setEnabled: vi.fn(async () => STATUS),
hintFor: vi.fn(async () => ({ hint: null })),
};
}
describe('WandererWatchController', () => {
it('WWCTL-001: sits beside the connection, behind Tours, auth and the wanderer switch', () => {
expect(Reflect.getMetadata(PATH_METADATA, WandererWatchController)).toBe('api/integrations/wanderer/watch');
expect(Reflect.getMetadata(GUARDS_METADATA, WandererWatchController)).toEqual([
AddonGuard,
JwtAuthGuard,
WandererEnabledGuard,
]);
expect(Reflect.getMetadata(REQUIRE_ADDON, WandererWatchController)).toEqual({ addonId: 'tours', label: 'Tours' });
expect(Reflect.getMetadata(METHOD_METADATA, WandererWatchController.prototype.status)).toBe(RequestMethod.GET);
expect(Reflect.getMetadata(METHOD_METADATA, WandererWatchController.prototype.setEnabled)).toBe(RequestMethod.PUT);
});
it('WWCTL-002: reads and switches only the caller own watch', async () => {
const service = watchService();
const controller = new WandererWatchController(service as unknown as WandererWatchService);
const user = { id: 4 } as User;
expect(await controller.status(user)).toBe(STATUS);
expect(service.status).toHaveBeenCalledExactlyOnceWith(4);
const req = { headers: {}, socket: { remoteAddress: '10.0.0.9' } } as unknown as Request;
expect(await controller.setEnabled(user, { enabled: false }, req)).toBe(STATUS);
expect(service.setEnabled).toHaveBeenCalledExactlyOnceWith(4, false, expect.anything());
});
});
describe('ToursWandererController hint route', () => {
it('WWCTL-003: asks place_edit like the check, and hands trip, user and tour to the watch', async () => {
const fn = ToursWandererController.prototype.hint;
expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(RequestMethod.GET);
expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe('sources/:placeId/hint');
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, fn)).toBe('place_edit');
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, ToursWandererController.prototype.checkSource)).toBe('place_edit');
expect(Reflect.getMetadata(GUARDS_METADATA, ToursWandererController)).toContain(TripAccessGuard);
const service = watchService();
const controller = new ToursWandererController(
{} as ToursWandererService,
service as unknown as WandererWatchService,
);
expect(await controller.hint('7', '3', { id: 4 } as User)).toEqual({ hint: null });
expect(service.hintFor).toHaveBeenCalledExactlyOnceWith('7', 4, '3');
});
});
@@ -0,0 +1,124 @@
/**
* WAN-02: the pure half of the wanderer watch: what a look at a trail's record
* says about a snapshot, when a hint is new, and how long a failing instance
* is left alone.
*/
import type { WatchedSourceRow } from '../../../src/db/repositories/TourSourceHints.repository';
import { contentHashOf, recordChangedParts } from '../../../src/nest/tours/tours.helpers';
import {
backoffMs,
isNewHint,
verdictOf,
WATCH_BACKOFF_BASE_MS,
WATCH_BACKOFF_MAX_MS,
} from '../../../src/nest/tours/wanderer-watch.helpers';
import { describe, expect, it } from 'vitest';
const ROUTE: [number, number][] = [
[47.45, 11.1],
[47.46, 11.11],
];
const HASH = contentHashOf('Ridge', 'Up the ridge', ROUTE);
function source(over: Partial<WatchedSourceRow> = {}): WatchedSourceRow {
return {
place_id: 3,
external_id: 'trail1',
content_hash: HASH,
external_updated: '2026-10-01 10:00:00.000Z',
seen_hash: null,
seen_updated: null,
state: null,
...over,
};
}
const record = (over: Partial<{ name: string; description: string | null; updated: string | null }> = {}) => ({
kind: 'record' as const,
name: 'Ridge',
description: 'Up the ridge',
updated: '2026-10-01 10:00:00.000Z',
...over,
});
describe('recordChangedParts', () => {
it('WWH-001: names the texts a record proves different, never the route', () => {
expect(recordChangedParts(HASH, ' Ridge ', 'Up the ridge')).toEqual([]);
expect(recordChangedParts(HASH, 'Ridge (new)', null)).toEqual(['name', 'description']);
expect(recordChangedParts(contentHashOf('Ridge', null, ROUTE), 'Ridge', '')).toEqual([]);
});
});
describe('verdictOf', () => {
it('WWH-002: the same texts and change time say nothing, least of all unchanged', () => {
expect(verdictOf(source(), record())).toEqual({ state: null, changes: [], reason: null });
expect(verdictOf(source(), record({ updated: '2026-10-01T10:00:00Z' }))).toEqual({
state: null,
changes: [],
reason: null,
});
});
it('WWH-003: a moved change time is only a possible change', () => {
expect(verdictOf(source(), record({ updated: '2026-10-14 08:00:00.000Z' }))).toEqual({
state: 'possible_change',
changes: [],
reason: null,
});
});
it('WWH-004: a different name or description is a proven change', () => {
expect(verdictOf(source(), record({ name: 'Ridge (new)', updated: '2026-10-14' }))).toEqual({
state: 'changed',
changes: ['name'],
reason: null,
});
});
it('WWH-005: a trail that is gone, or a change time missing on either side, cannot be judged', () => {
const unavailable = { state: 'unverifiable', changes: [], reason: 'unavailable' };
expect(verdictOf(source(), { kind: 'gone' })).toEqual(unavailable);
expect(verdictOf(source(), undefined)).toEqual(unavailable);
const noTime = { state: 'unverifiable', changes: [], reason: 'no_change_time' };
expect(verdictOf(source({ external_updated: null }), record())).toEqual(noTime);
expect(verdictOf(source(), record({ updated: null }))).toEqual(noTime);
expect(verdictOf(source(), record({ updated: 'not a date' }))).toMatchObject({ state: 'possible_change' });
});
it('WWH-006: after a manual check the look compares with what the user saw there', () => {
const seen = source({
seen_hash: contentHashOf('Ridge (new)', 'Up the ridge', ROUTE),
seen_updated: '2026-10-14 08:00:00.000Z',
});
expect(verdictOf(seen, record({ name: 'Ridge (new)', updated: '2026-10-14 08:00:00.000Z' }))).toEqual({
state: null,
changes: [],
reason: null,
});
expect(verdictOf(seen, record({ name: 'Ridge (new)', updated: '2026-10-20 08:00:00.000Z' }))).toMatchObject({
state: 'possible_change',
});
expect(verdictOf(source({ seen_hash: HASH, seen_updated: null }), record())).toMatchObject({
reason: 'no_change_time',
});
});
});
describe('isNewHint and backoffMs', () => {
it('WWH-007: a hint is new when there is something to say that the last look did not say', () => {
const possible = { state: 'possible_change' as const, changes: [], reason: null };
expect(isNewHint(source(), possible)).toBe(true);
expect(isNewHint(source({ state: 'possible_change' }), possible)).toBe(false);
expect(isNewHint(source({ state: 'possible_change' }), { ...possible, state: 'changed' })).toBe(true);
expect(isNewHint(source({ state: 'changed' }), { state: null, changes: [], reason: null })).toBe(false);
});
it('WWH-008: each failure in a row doubles the wait, up to its ceiling', () => {
expect(backoffMs(1)).toBe(WATCH_BACKOFF_BASE_MS);
expect(backoffMs(2)).toBe(2 * WATCH_BACKOFF_BASE_MS);
expect(backoffMs(4)).toBe(8 * WATCH_BACKOFF_BASE_MS);
expect(backoffMs(30)).toBe(WATCH_BACKOFF_MAX_MS);
expect(backoffMs(0)).toBe(WATCH_BACKOFF_BASE_MS);
});
});
@@ -0,0 +1,59 @@
/**
* WAN-02: the hourly provider of the wanderer watch: registration, the test
* gate, and that a failing run cannot take the scheduler down with it.
*/
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
import { WandererWatchJob } from '../../../src/nest/tours/wanderer-watch.job';
import type { WandererWatchService } from '../../../src/nest/tours/wanderer-watch.service';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const logMock = vi.hoisted(() => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
logDebug: vi.fn(),
}));
vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
function makeJob(run: () => Promise<void>, enabled = true) {
const registrar = { isEnabled: vi.fn(() => enabled), register: vi.fn(() => enabled), unregister: vi.fn() };
const watch = { run: vi.fn(run) };
const job = new WandererWatchJob(
watch as unknown as WandererWatchService,
registrar as unknown as CronRegistrarService,
);
return { job, registrar, watch };
}
beforeEach(() => vi.clearAllMocks());
describe('WandererWatchJob', () => {
it('WWJOB-001: registers hourly at minute 23', () => {
const { job, registrar } = makeJob(async () => undefined);
job.onApplicationBootstrap();
expect(registrar.register).toHaveBeenCalledWith('wanderer-watch', '23 * * * *', expect.any(Function));
});
it('WWJOB-002: does not register under the test gate', () => {
const { job, registrar } = makeJob(async () => undefined, false);
job.onApplicationBootstrap();
expect(registrar.register).not.toHaveBeenCalled();
});
it('WWJOB-003: a tick runs the watch and contains its failure', async () => {
const ok = makeJob(async () => undefined);
await ok.job.tick();
expect(ok.watch.run).toHaveBeenCalledOnce();
const failing = makeJob(async () => {
throw new Error('database is locked');
});
await expect(failing.job.tick()).resolves.toBeUndefined();
expect(logMock.logError).toHaveBeenCalledWith(expect.stringContaining('database is locked'));
const odd = makeJob(() => Promise.reject('plain'));
await odd.job.tick();
expect(logMock.logError).toHaveBeenLastCalledWith(expect.stringContaining('plain'));
});
});
@@ -0,0 +1,323 @@
/**
* WandererWatchService (WAN-02): the optional background watch over a user's
* own wanderer snapshots. The service comes out of ToursModule over a snapshot
* test db with its real repositories; only the wanderer client (no socket is
* ever opened) and the notifications (a spy) are replaced.
*/
import { Places } from '../../../src/db/entities/Places.entity';
import { TourSourceHints } from '../../../src/db/entities/TourSourceHints.entity';
import { WandererConnections } from '../../../src/db/entities/WandererConnections.entity';
import { maybe_encrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCrypto';
import { DomainError } from '../../../src/nest/common/domain-error';
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
import { contentHashOf } from '../../../src/nest/tours/tours.helpers';
import { WATCH_BACKOFF_BASE_MS } from '../../../src/nest/tours/wanderer-watch.helpers';
import { WandererWatchService } from '../../../src/nest/tours/wanderer-watch.service';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { WandererClient, WandererError } from '../../../src/nest/wanderer/wanderer.client';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makePlace } from '../../helpers/factories/places';
import { countRows, deleteRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
import { setAddonEnabled, setAppSetting } from '../../helpers/factories/settings';
import { makeTour, makeTourSource } from '../../helpers/factories/tours';
import { makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
const INSTANCE = 'https://wanderer.example.org';
const ROUTE: [number, number][] = [
[47.45, 11.1],
[47.46, 11.11],
];
const DAY = 24 * 60 * 60 * 1000;
const NOW = new Date('2026-10-20T10:00:00Z');
const later = (ms: number) => new Date(NOW.getTime() + ms);
const RECORD = {
id: 'trail1',
name: 'Ridge',
description: 'Up the ridge',
gpx: 'track.gpx',
updated: '2026-10-01 10:00:00.000Z',
author: 'actor1',
};
const client = {
check: vi.fn(),
ownActors: vi.fn(),
search: vi.fn(),
searchById: vi.fn(),
trail: vi.fn(),
gpx: vi.fn(),
};
const notifications = { send: vi.fn() };
const db = createSnapshotTestDb();
let t: TestModule;
let watch: WandererWatchService;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({
db,
imports: [ToursModule],
overrides: [
{ provide: WandererClient, useValue: client },
{ provide: NotificationsService, useValue: notifications },
],
});
watch = t.get(WandererWatchService);
});
beforeEach(async () => {
resetTestDb(db);
vi.clearAllMocks();
client.ownActors.mockResolvedValue(['actor1']);
client.trail.mockResolvedValue(RECORD);
notifications.send.mockResolvedValue({ attempted: 1, delivered: 1 });
await setAddonEnabled(orm(), 'tours', true);
await setAppSetting(orm(), 'tours_wanderer_enabled', 'true');
});
afterAll(async () => {
await t.close();
db.close();
});
/** A user with a watched connection and one wanderer snapshot, imported as RECORD said then. */
async function fixture(watchEnabled = true) {
const { user } = await makeUser(orm());
await insertRow(orm(), WandererConnections, {
user: user.id,
url: INSTANCE,
token: maybe_encrypt_api_key('wanderer_key_abc'),
watch_enabled: watchEnabled ? 1 : 0,
});
const trip = await makeTrip(orm(), user.id, { title: 'Alps' });
const place = await makePlace(orm(), trip.id, { name: 'Ridge' });
await makeTour(orm(), place.id, { origin: 'wanderer' });
await makeTourSource(orm(), place.id, user.id, {
instance: INSTANCE,
content_hash: contentHashOf(RECORD.name, RECORD.description, ROUTE),
external_updated: RECORD.updated,
});
return { user, trip, placeId: place.id };
}
const hintRow = (placeId: number) => findRow(orm(), TourSourceHints, { place: placeId });
const connection = (userId: number) => findRow(orm(), WandererConnections, { user: userId });
describe('WandererWatchService.run', () => {
it('WWATCH-001: asks nothing while the watch is off, or wanderer is off', async () => {
await fixture(false);
await watch.run(NOW);
expect(client.ownActors).not.toHaveBeenCalled();
const { user } = await fixture(true);
await setAppSetting(orm(), 'tours_wanderer_enabled', 'false');
await watch.run(NOW);
expect(client.ownActors).not.toHaveBeenCalled();
expect(await connection(user.id)).toMatchObject({ watch_last_run_at: null });
});
it('WWATCH-002: a record as imported writes a quiet look, tells nobody and never fetches a GPX', async () => {
const { user, placeId } = await fixture();
const before = await countRows(orm(), Places);
await watch.run(NOW);
expect(client.trail).toHaveBeenCalledTimes(1);
expect(client.gpx).not.toHaveBeenCalled();
expect(await hintRow(placeId)).toMatchObject({ state: null, checked_at: '2026-10-20 10:00:00' });
expect(await connection(user.id)).toMatchObject({ watch_last_run_at: '2026-10-20 10:00:00', watch_failures: 0 });
expect(notifications.send).not.toHaveBeenCalled();
expect(await countRows(orm(), Places)).toBe(before);
expect((await watch.status(user.id)).hints).toEqual([]);
});
it('WWATCH-003: a moved change time leaves a possible change, told once, and a source rests a day', async () => {
const { user, trip, placeId } = await fixture();
client.trail.mockResolvedValue({ ...RECORD, updated: '2026-10-14 08:00:00.000Z' });
await watch.run(NOW);
expect(await hintRow(placeId)).toMatchObject({
state: 'possible_change',
external_updated: '2026-10-14 08:00:00.000Z',
});
expect(notifications.send).toHaveBeenCalledExactlyOnceWith({
event: 'wanderer_source_hint',
actorId: null,
scope: 'user',
targetId: user.id,
params: {
state: 'possible_change',
tour: 'Ridge',
trip: 'Alps',
tripId: String(trip.id),
placeId: String(placeId),
},
});
await watch.run(later(DAY / 2));
expect(client.trail).toHaveBeenCalledTimes(1);
// A second past the day: the stored times keep whole seconds.
await watch.run(later(DAY + 1000));
expect(client.trail).toHaveBeenCalledTimes(2);
expect(notifications.send).toHaveBeenCalledTimes(1);
});
it('WWATCH-004: a different name is a proven change, named as such', async () => {
const { placeId } = await fixture();
client.trail.mockResolvedValue({ ...RECORD, name: 'Ridge (new)', updated: '2026-10-14' });
await watch.run(NOW);
expect(await hintRow(placeId)).toMatchObject({ state: 'changed', changes: '["name"]', reason: null });
expect(notifications.send.mock.calls[0]?.[0]).toMatchObject({ params: { state: 'changed' } });
});
it('WWATCH-005: a trail that is gone or no longer the user own cannot be judged, and the tour stays', async () => {
const { placeId } = await fixture();
client.trail.mockRejectedValue(new WandererError('not_found', 'gone'));
await watch.run(NOW);
expect(await hintRow(placeId)).toMatchObject({ state: 'unverifiable', reason: 'unavailable' });
expect(await findRow(orm(), Places, { id: placeId })).not.toBeNull();
});
it('WWATCH-006: a failing instance gets nothing written and is left alone for longer each time', async () => {
const { user, placeId } = await fixture();
client.trail.mockRejectedValue(new WandererError('server_error', 'HTTP 429'));
await watch.run(NOW);
expect(await hintRow(placeId)).toBeNull();
expect(await connection(user.id)).toMatchObject({ watch_failures: 1, watch_paused_until: '2026-10-20 11:00:00' });
await watch.run(later(WATCH_BACKOFF_BASE_MS / 2));
expect(client.trail).toHaveBeenCalledTimes(1);
await watch.run(later(WATCH_BACKOFF_BASE_MS));
expect(await connection(user.id)).toMatchObject({ watch_failures: 2, watch_paused_until: '2026-10-20 13:00:00' });
client.trail.mockResolvedValue(RECORD);
await watch.run(later(4 * WATCH_BACKOFF_BASE_MS));
expect(await connection(user.id)).toMatchObject({ watch_failures: 0, watch_paused_until: null });
});
it('WWATCH-007: a refused token stops the watch until a new check passes', async () => {
const { user } = await fixture();
client.ownActors.mockRejectedValue(new WandererError('unauthorized', 'revoked'));
await watch.run(NOW);
expect((await watch.status(user.id)).blocked).toBe(true);
await watch.run(later(DAY * 3));
expect(client.ownActors).toHaveBeenCalledTimes(1);
});
it('WWATCH-008: a removed connection ends the looks; the snapshot and its hint stay unread', async () => {
const { user, placeId } = await fixture();
client.trail.mockResolvedValue({ ...RECORD, updated: '2026-10-14' });
await watch.run(NOW);
await deleteRows(orm(), WandererConnections, { user: user.id });
await watch.run(later(DAY * 2));
expect(client.trail).toHaveBeenCalledTimes(1);
expect(await hintRow(placeId)).toMatchObject({ state: 'possible_change' });
expect(await watch.status(user.id)).toMatchObject({ connected: false, hints: [] });
});
it('WWATCH-009: nothing due asks the instance nothing, and a failed notice is only logged', async () => {
const { user } = await fixture();
await watch.run(NOW);
client.ownActors.mockClear();
await watch.run(later(DAY / 4));
expect(client.ownActors).not.toHaveBeenCalled();
expect(await connection(user.id)).toMatchObject({ watch_last_run_at: '2026-10-20 16:00:00' });
const other = await fixture();
notifications.send.mockRejectedValue(new Error('smtp down'));
client.trail.mockResolvedValue({ ...RECORD, updated: '2026-10-14' });
await expect(watch.run(later(DAY / 2))).resolves.toBeUndefined();
expect(await hintRow(other.placeId)).toMatchObject({ state: 'possible_change' });
});
it('WWATCH-010: a run already going is not started twice', async () => {
await fixture();
let release: () => void = () => undefined;
client.ownActors.mockImplementation(
() =>
new Promise((resolve) => {
release = () => resolve(['actor1']);
}),
);
const first = watch.run(NOW);
await vi.waitFor(() => expect(client.ownActors).toHaveBeenCalled());
await watch.run(NOW);
release();
await first;
expect(client.ownActors).toHaveBeenCalledTimes(1);
});
});
describe('WandererWatchService switch, status and hints', () => {
it('WWATCH-011: switching on needs a connection; the status lists the open hints with their tour', async () => {
const { user: stranger } = await makeUser(orm());
await expect(watch.setEnabled(stranger.id, true, null)).rejects.toBeInstanceOf(DomainError);
const { user, trip, placeId } = await fixture(false);
expect(await watch.setEnabled(user.id, true, null)).toMatchObject({ enabled: true, connected: true, hints: [] });
client.trail.mockResolvedValue({ ...RECORD, updated: '2026-10-14' });
await watch.run(NOW);
expect(await watch.status(user.id)).toMatchObject({
enabled: true,
lastRunAt: '2026-10-20T10:00:00Z',
pausedUntil: null,
blocked: false,
hints: [
{
placeId,
tripId: trip.id,
tripTitle: 'Alps',
tourName: 'Ridge',
state: 'possible_change',
changes: [],
reason: null,
externalUpdated: '2026-10-14',
checkedAt: '2026-10-20T10:00:00Z',
},
],
});
expect(await watch.setEnabled(user.id, false, null)).toMatchObject({ enabled: false });
await watch.run(later(DAY * 2));
expect(client.trail).toHaveBeenCalledTimes(1);
});
it('WWATCH-012: the hint of one tour is the caller own, and a manual check clears it', async () => {
const { user, trip, placeId } = await fixture();
const { user: member } = await makeUser(orm());
client.trail.mockResolvedValue({ ...RECORD, name: 'Ridge (new)', updated: '2026-10-14' });
await watch.run(NOW);
expect(await watch.hintFor(String(trip.id), user.id, String(placeId))).toEqual({
hint: expect.objectContaining({ state: 'changed', changes: ['name'] }),
});
expect(await watch.hintFor(String(trip.id), member.id, String(placeId))).toEqual({ hint: null });
expect(await watch.hintFor('x', user.id, String(placeId))).toEqual({ hint: null });
await watch.noteSeen(placeId, user.id, {
contentHash: contentHashOf('Ridge (new)', RECORD.description, ROUTE),
externalUpdated: '2026-10-14',
});
expect(await watch.hintFor(String(trip.id), user.id, String(placeId))).toEqual({ hint: null });
await watch.run(later(DAY * 2));
expect(await hintRow(placeId)).toMatchObject({ state: null, seen_updated: '2026-10-14' });
expect(notifications.send).toHaveBeenCalledTimes(1);
});
it('WWATCH-013: a stored change list that is broken reads as none', async () => {
const { user, trip, placeId } = await fixture();
client.trail.mockResolvedValue({ ...RECORD, name: 'Other' });
await watch.run(NOW);
await updateRows(orm(), TourSourceHints, { place: placeId }, { changes: 'not json' });
expect((await watch.hintFor(String(trip.id), user.id, String(placeId))).hint).toMatchObject({ changes: [] });
});
});
+105 -2
View File
@@ -39,12 +39,18 @@ function hit(over: Partial<WandererSearchHit> = {}): WandererSearchHit {
return { id: 'trail1', name: 'Ridge', ...over };
}
const state: { row: Row } = { row: undefined };
type Watch = Awaited<ReturnType<WandererConnectionsRepository['findWatch']>>;
const state: { row: Row; watch: Watch } = { row: undefined, watch: undefined };
const connections = {
findRow: vi.fn(async () => state.row),
save: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
recordCheck: vi.fn(async () => undefined),
findWatch: vi.fn(async () => state.watch),
setWatch: vi.fn(async () => undefined),
listWatchable: vi.fn(async () => [3, 4]),
recordWatchRun: vi.fn(async () => undefined),
};
const client = {
check: vi.fn(),
@@ -77,8 +83,9 @@ afterAll(async () => {
});
/** The service over a saved row (or none), every collaborator answering the happy path. */
function setup(row: Row = undefined) {
function setup(row: Row = undefined, watch: Watch = undefined) {
state.row = row;
state.watch = watch;
for (const mock of [...Object.values(connections), ...Object.values(client), audit.writeAudit]) mock.mockClear();
client.check.mockReset().mockResolvedValue({ trailCount: 4 });
client.ownActors.mockReset().mockResolvedValue(['actor1']);
@@ -375,3 +382,99 @@ describe('WandererService reads for an import', () => {
await expect(service.download(1, 'trail1')).rejects.toThrow(TypeError);
});
});
describe('WandererService background watch (WAN-02)', () => {
it('WAND-SVC-020: the watch state reads the switch, the last run and a refused token', async () => {
const none = setup();
expect(await none.service.watchState(1)).toEqual({
connected: false,
enabled: false,
lastRunAt: null,
failures: 0,
pausedUntil: null,
blocked: false,
});
const watched = setup(savedRow({ last_check_ok: 0, last_check_error: 'unauthorized' }), {
watch_enabled: 1,
watch_last_run_at: '2026-10-19 10:00:00',
watch_failures: 2,
watch_paused_until: '2026-10-19 14:00:00',
});
expect(await watched.service.watchState(1)).toEqual({
connected: true,
enabled: true,
lastRunAt: '2026-10-19 10:00:00',
failures: 2,
pausedUntil: '2026-10-19 14:00:00',
blocked: true,
});
});
it('WAND-SVC-021: switching on needs a connection; either way the switch is audited', async () => {
const none = setup();
expect((await refusal(none.service.setWatch(1, true, null))).getStatus()).toBe(409);
expect(none.connections.setWatch).not.toHaveBeenCalled();
const { service, connections, audit } = setup(savedRow());
await service.setWatch(1, true, '10.0.0.1');
expect(connections.setWatch).toHaveBeenCalledWith(1, true);
expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'wanderer.watch_enabled' }));
await setup().service.setWatch(1, false, null);
expect(connections.setWatch).toHaveBeenLastCalledWith(1, false);
expect(audit.writeAudit).toHaveBeenLastCalledWith(expect.objectContaining({ action: 'wanderer.watch_disabled' }));
});
it('WAND-SVC-022: lists the watchable users and notes a run as given', async () => {
const { service, connections } = setup(savedRow());
expect(await service.watchableUserIds('2026-10-19 10:00:00', 5)).toEqual([3, 4]);
expect(connections.listWatchable).toHaveBeenCalledWith('2026-10-19 10:00:00', 5);
const run = { lastRunAt: '2026-10-19 10:00:00', failures: 0, pausedUntil: null };
await service.noteWatchRun(1, run);
expect(connections.recordWatchRun).toHaveBeenCalledWith(1, run);
});
it('WAND-SVC-023: reads each trail record once, never a GPX, and calls the gone ones gone', async () => {
const { service, client } = setup(savedRow());
client.trail.mockImplementation(async (_creds: unknown, id: string) => {
if (id === 'deleted') throw new WandererError('not_found', 'gone');
if (id === 'hidden') throw new WandererError('forbidden', 'no');
if (id === 'theirs') return { id, name: 'Theirs', author: 'stranger' };
return { id, name: 'Ridge', description: 'Up', updated: '2026-10-02', author: 'actor1' };
});
const read = await service.lookAtTrails(1, ['trail1', 'trail1', 'deleted', 'hidden', 'theirs']);
expect(read.ok).toBe(true);
if (!read.ok) return;
expect(Object.fromEntries(read.looks)).toEqual({
trail1: { kind: 'record', name: 'Ridge', description: 'Up', updated: '2026-10-02' },
deleted: { kind: 'gone' },
hidden: { kind: 'gone' },
theirs: { kind: 'gone' },
});
expect(client.trail).toHaveBeenCalledTimes(4);
expect(client.gpx).not.toHaveBeenCalled();
client.trail.mockResolvedValue({ id: 'bare', name: 'Bare', author: 'actor1' });
const bare = await service.lookAtTrails(1, ['bare']);
expect(bare.ok && bare.looks.get('bare')).toEqual({
kind: 'record',
name: 'Bare',
description: null,
updated: null,
});
});
it('WAND-SVC-024: a failing instance ends the look with its reason, and a refused token is noted', async () => {
const { service, client, connections } = setup(savedRow());
client.trail.mockRejectedValue(new WandererError('unreachable', 'down'));
expect(await service.lookAtTrails(1, ['trail1'])).toEqual({ ok: false, code: 'unreachable' });
expect(connections.recordCheck).not.toHaveBeenCalled();
client.ownActors.mockRejectedValue(new WandererError('unauthorized', 'revoked'));
expect(await service.lookAtTrails(1, ['trail1'])).toEqual({ ok: false, code: 'unauthorized' });
expect(connections.recordCheck).toHaveBeenCalledWith(1, false, 'unauthorized');
client.ownActors.mockRejectedValue(new Error('bug'));
await expect(service.lookAtTrails(1, ['trail1'])).rejects.toThrow('bug');
expect((await refusal(setup().service.lookAtTrails(1, ['trail1']))).getStatus()).toBe(409);
});
});
@@ -90,6 +90,15 @@ const ar: NotificationLocale = {
title: 'تمت إعادة تعيين جلسة Synology',
body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.',
}),
wanderer_source_hint: (p) => ({
title: `درب wanderer: ${p.tour}`,
body:
p.state === 'changed'
? `تغيّر «${p.tour}» في «${p.trip}» على wanderer. افتح الجولة وتحقق من مصدرها لترى كل التغييرات. لا يتغير شيء في الرحلة من تلقاء نفسه.`
: p.state === 'unverifiable'
? `لم يعد بالإمكان التحقق من «${p.tour}» في «${p.trip}» على wanderer. تبقى الجولة في رحلتك كما هي.`
: `ربما تغيّر «${p.tour}» في «${p.trip}» على wanderer. افتح الجولة وتحقق من مصدرها. لا يتغير شيء في الرحلة من تلقاء نفسه.`,
}),
plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }),
},
passwordReset: {
+2
View File
@@ -55,6 +55,7 @@ import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import wanderer from './wanderer';
import wandererWatch from './wandererWatch';
const locale = {
...common,
@@ -66,6 +67,7 @@ const locale = {
...tourCollection,
...tourPacking,
...wanderer,
...wandererWatch,
...settings,
...admin,
...dayplan,
+5
View File
@@ -45,6 +45,11 @@ const notif: TranslationStrings = {
'notif.action.view_vacay': 'عرض Vacay',
'notif.action.view_collection': 'عرض المجموعة',
'notif.action.view_admin': 'الذهاب للإدارة',
'notif.wanderer_source_hint.title': 'درب wanderer',
'notif.wanderer_source_hint.possible_change': 'ربما تغيّر «{tour}» في {trip} على wanderer',
'notif.wanderer_source_hint.changed': 'تغيّر «{tour}» في {trip} على wanderer',
'notif.wanderer_source_hint.unverifiable': 'لم يعد بالإمكان التحقق من «{tour}» في {trip} على wanderer',
'notif.action.view_tour': 'عرض الجولة',
'notif.action.view': 'عرض',
'notif.action.accept': 'قبول',
'notif.action.decline': 'رفض',
+1
View File
@@ -101,6 +101,7 @@ const settings: TranslationStrings = {
'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
'settings.notifyWandererSourceHint': 'wanderer: تنبيهات حول دروبك',
'settings.notifyWebhook': 'إشعارات Webhook',
'settings.notifyVersionAvailable': 'إصدار جديد متاح',
'settings.notifyReplicaFailure': 'فشل النسخة المتماثلة للتخزين',
+40
View File
@@ -0,0 +1,40 @@
import type { TranslationStrings } from '../types';
const wandererWatch: TranslationStrings = {
'wanderer.watch.label': 'التحقق من تغييرات دروبي',
'wanderer.watch.hint': 'إشعار عندما يكون درب مستورد قد تغيّر.',
'wanderer.watch.info':
'مرة واحدة في اليوم على الأكثر، يقرأ TREK السجل المختصر لكل درب استوردته، ولا يقرأ ملف GPX الخاص به أبدًا. إنه يُشعرك فقط: أنت من يقرر قبول التغيير عبر التحقق من مصدر الجولة.',
'wanderer.watch.notConnected': 'اربط wanderer أولًا',
'wanderer.watch.on': 'تم تشغيل التحقق في الخلفية',
'wanderer.watch.off': 'تم إيقاف التحقق في الخلفية',
'wanderer.watch.failed': 'تعذّر تبديل التحقق في الخلفية',
'wanderer.watch.lastRun': 'آخر تحقق {date}',
'wanderer.watch.neverRun': 'لم يُتحقق بعد',
'wanderer.watch.paused': 'متوقف مؤقتًا حتى {time}',
'wanderer.watch.pausedHint': 'لم يستجب wanderer. بعد كل إخفاق ينتظر TREK مدة أطول قبل أن يسأل مجددًا.',
'wanderer.watch.blocked': 'رُفض الرمز',
'wanderer.watch.blockedHint': 'رفض wanderer رمز API. يستأنف التحقق عندما ينجح اختبار الاتصال أو يُحفظ رمز جديد.',
'wanderer.watch.hints': 'تنبيهات',
'wanderer.watch.hintsCount': '{count} تنبيه مفتوح',
'wanderer.watch.hintsCount.zero': 'لا توجد تنبيهات مفتوحة',
'wanderer.watch.hintsCount.one': 'تنبيه مفتوح واحد',
'wanderer.watch.hintsCount.two': 'تنبيهان مفتوحان',
'wanderer.watch.hintsCount.few': '{count} تنبيهات مفتوحة',
'wanderer.watch.hintsCount.many': '{count} تنبيهًا مفتوحًا',
'wanderer.watch.empty': 'لا توجد تنبيهات مفتوحة',
'wanderer.watch.dialogTitle': 'تنبيهات حول دروبك',
'wanderer.watch.openTour': 'فتح الجولة',
'wanderer.watch.checkedAt': 'تم التحقق {date}',
'wanderer.watch.noticeLabel': 'التحقق في الخلفية',
'wanderer.watch.state.possible_change': 'ربما تغيّر',
'wanderer.watch.state.changed': 'تغيّر',
'wanderer.watch.state.unverifiable': 'لا يمكن التحقق',
'wanderer.watch.stateHint.possible_change':
'غيّر wanderer الطابع الزمني للدرب. وحده التحقق من المصدر يُظهر ما إذا كان المحتوى مختلفًا.',
'wanderer.watch.stateHint.changed': 'يُظهر سجل wanderer اسمًا أو وصفًا آخر. يُظهر التحقق من المصدر كل ما تغيّر.',
'wanderer.watch.stateHint.unverifiable': 'لم يتمكن TREK من تقييم هذا الدرب، لذا لا يُقال شيء عن تغيّره.',
'wanderer.watch.reason.unavailable': 'الدرب غير متاح',
'wanderer.watch.reason.no_change_time': 'لا يوجد طابع زمني',
};
export default wandererWatch;
@@ -75,6 +75,15 @@ const az: NotificationLocale = {
title: 'Synology sessiyası təmizləndi',
body: 'Synology hesabınız və ya URL-iniz dəyişib. Synology Photos sessiyanız bağlanıb.',
}),
wanderer_source_hint: (p) => ({
title: `wanderer marşrutu: ${p.tour}`,
body:
p.state === 'changed'
? `"${p.trip}" içində "${p.tour}" wanderer-də dəyişdirildi. Bütün dəyişiklikləri görmək üçün turu aç və mənbəyini yoxla. Səfərdə heç nə özbaşına dəyişmir.`
: p.state === 'unverifiable'
? `"${p.trip}" içində "${p.tour}" artıq wanderer-də yoxlanıla bilmir. Səfərindəki tur olduğu kimi qalır.`
: `"${p.trip}" içində "${p.tour}" wanderer-də dəyişmiş ola bilər. Turu aç və mənbəyini yoxla. Səfərdə heç nə özbaşına dəyişmir.`,
}),
plugin_notification: (p) => ({
title: p.title ?? '',
body: p.body ?? '',
+2
View File
@@ -55,6 +55,7 @@ import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import wanderer from './wanderer';
import wandererWatch from './wandererWatch';
const locale = {
...common,
@@ -76,6 +77,7 @@ const locale = {
...tourCollection,
...tourPacking,
...wanderer,
...wandererWatch,
...places,
...inspector,
...reservations,
+5
View File
@@ -41,6 +41,11 @@ const notif: TranslationStrings = {
'notif.action.view_vacay': 'Tətilə bax',
'notif.action.view_collection': 'Kolleksiyaya bax',
'notif.action.view_admin': 'İdarəetməyə keç',
'notif.wanderer_source_hint.title': 'wanderer marşrutu',
'notif.wanderer_source_hint.possible_change': '{trip} içində "{tour}" wanderer-də dəyişmiş ola bilər',
'notif.wanderer_source_hint.changed': '{trip} içində "{tour}" wanderer-də dəyişdirildi',
'notif.wanderer_source_hint.unverifiable': '{trip} içində "{tour}" artıq wanderer-də yoxlanıla bilmir',
'notif.action.view_tour': 'Tura bax',
'notif.action.view': 'Bax',
'notif.action.accept': 'Qəbul et',
'notif.action.decline': 'İmtina et',
+1
View File
@@ -134,6 +134,7 @@ const settings: TranslationStrings = {
'settings.notifyPhotosShared': 'Paylaşılan fotolar (Immich)',
'settings.notifyCollabMessage': 'Çat mesajları (Collab)',
'settings.notifyPackingTagged': 'Baqaj siyahısı: təyinatlar',
'settings.notifyWandererSourceHint': 'wanderer: marşrutların barədə xəbərdarlıqlar',
'settings.notifyWebhook': 'Webhook bildirişləri',
'settings.notifyVersionAvailable': 'Yeni versiya əlçatandır',
'settings.notifyReplicaFailure': 'Yaddaş replikası xətası',
+40
View File
@@ -0,0 +1,40 @@
import type { TranslationStrings } from '../types';
const wandererWatch: TranslationStrings = {
'wanderer.watch.label': 'Marşrutlarımdakı dəyişiklikləri yoxla',
'wanderer.watch.hint': 'İdxal edilmiş marşrut dəyişmiş ola bilərsə bildiriş.',
'wanderer.watch.info':
'TREK idxal etdiyin hər marşrutun qısa qeydini gündə ən çox bir dəfə oxuyur, GPX faylını isə heç vaxt. O, yalnız bildirir: dəyişikliyin qəbul edilib-edilməməsinə turun mənbə yoxlaması ilə özün qərar verirsən.',
'wanderer.watch.notConnected': 'Əvvəlcə wanderer qoş',
'wanderer.watch.on': 'Fon yoxlaması açıldı',
'wanderer.watch.off': 'Fon yoxlaması bağlandı',
'wanderer.watch.failed': 'Fon yoxlamasını dəyişmək alınmadı',
'wanderer.watch.lastRun': 'Son yoxlama {date}',
'wanderer.watch.neverRun': 'Hələ yoxlanmayıb',
'wanderer.watch.paused': '{time} vaxtınadək dayandırılıb',
'wanderer.watch.pausedHint':
'wanderer cavab vermədi. Hər uğursuzluqdan sonra TREK yenidən soruşmazdan əvvəl daha uzun gözləyir.',
'wanderer.watch.blocked': 'Token rədd edildi',
'wanderer.watch.blockedHint':
'wanderer API tokenini rədd etdi. Bağlantı testi keçəndə və ya yeni token saxlananda yoxlama davam edir.',
'wanderer.watch.hints': 'Xəbərdarlıqlar',
'wanderer.watch.hintsCount': '{count} açıq xəbərdarlıq',
'wanderer.watch.hintsCount.one': '{count} açıq xəbərdarlıq',
'wanderer.watch.empty': 'Açıq xəbərdarlıq yoxdur',
'wanderer.watch.dialogTitle': 'Marşrutların barədə xəbərdarlıqlar',
'wanderer.watch.openTour': 'Turu aç',
'wanderer.watch.checkedAt': 'Yoxlanıb {date}',
'wanderer.watch.noticeLabel': 'Fon yoxlaması',
'wanderer.watch.state.possible_change': 'Dəyişmiş ola bilər',
'wanderer.watch.state.changed': 'Dəyişib',
'wanderer.watch.state.unverifiable': 'Yoxlanıla bilmir',
'wanderer.watch.stateHint.possible_change':
'wanderer marşrutun vaxt möhürünü dəyişdi. Məzmunun fərqli olub-olmadığını yalnız mənbə yoxlaması göstərir.',
'wanderer.watch.stateHint.changed':
'wanderer qeydi başqa ad və ya təsvir göstərir. Mənbə yoxlaması dəyişən hər şeyi göstərir.',
'wanderer.watch.stateHint.unverifiable':
'TREK bu marşrutu qiymətləndirə bilmədi, ona görə də dəyişiklik barədə heç nə deyilmir.',
'wanderer.watch.reason.unavailable': 'Marşruta çatmaq olmur',
'wanderer.watch.reason.no_change_time': 'Vaxt möhürü yoxdur',
};
export default wandererWatch;
@@ -78,6 +78,15 @@ const br: NotificationLocale = {
title: 'Sessão Synology encerrada',
body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.',
}),
wanderer_source_hint: (p) => ({
title: `Trilha do wanderer: ${p.tour}`,
body:
p.state === 'changed'
? `“${p.tour}” em “${p.trip}” foi alterada no wanderer. Abra o passeio e verifique a fonte para ver todas as mudanças. Nada na viagem muda sozinho.`
: p.state === 'unverifiable'
? `“${p.tour}” em “${p.trip}” não pode mais ser verificada no wanderer. O passeio da sua viagem continua como está.`
: `“${p.tour}” em “${p.trip}” pode ter mudado no wanderer. Abra o passeio e verifique a fonte para saber. Nada na viagem muda sozinho.`,
}),
plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }),
},
passwordReset: {
+2
View File
@@ -55,6 +55,7 @@ import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import wanderer from './wanderer';
import wandererWatch from './wandererWatch';
const locale = {
...common,
@@ -66,6 +67,7 @@ const locale = {
...tourCollection,
...tourPacking,
...wanderer,
...wandererWatch,
...settings,
...admin,
...dayplan,
+5
View File
@@ -41,6 +41,11 @@ const notif: TranslationStrings = {
'notif.action.view_vacay': 'Ver Vacay',
'notif.action.view_collection': 'Ver coleção',
'notif.action.view_admin': 'Ir para admin',
'notif.wanderer_source_hint.title': 'Trilha do wanderer',
'notif.wanderer_source_hint.possible_change': '“{tour}” em {trip} pode ter mudado no wanderer',
'notif.wanderer_source_hint.changed': '“{tour}” em {trip} foi alterada no wanderer',
'notif.wanderer_source_hint.unverifiable': '“{tour}” em {trip} não pode mais ser verificada no wanderer',
'notif.action.view_tour': 'Ver passeio',
'notif.action.view': 'Ver',
'notif.action.accept': 'Aceitar',
'notif.action.decline': 'Recusar',
+1
View File
@@ -105,6 +105,7 @@ const settings: TranslationStrings = {
'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)',
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
'settings.notifyPackingTagged': 'Lista de mala: atribuições',
'settings.notifyWandererSourceHint': 'wanderer: avisos sobre suas trilhas',
'settings.notifyWebhook': 'Notificações webhook',
'settings.notificationsDisabled':
'As notificações não estão configuradas. Peça a um administrador para ativar notificações por e-mail ou webhook.',
+40
View File
@@ -0,0 +1,40 @@
import type { TranslationStrings } from '../types';
const wandererWatch: TranslationStrings = {
'wanderer.watch.label': 'Verificar mudanças nas minhas trilhas',
'wanderer.watch.hint': 'Uma notificação quando uma trilha importada pode ter mudado.',
'wanderer.watch.info':
'No máximo uma vez por dia, o TREK lê o registro curto de cada trilha que você importou, nunca o arquivo GPX. Ele só avisa: incorporar uma mudança é decisão sua, pela verificação da fonte do passeio.',
'wanderer.watch.notConnected': 'Conecte o wanderer primeiro',
'wanderer.watch.on': 'Verificação em segundo plano ativada',
'wanderer.watch.off': 'Verificação em segundo plano desativada',
'wanderer.watch.failed': 'Não foi possível alterar a verificação em segundo plano',
'wanderer.watch.lastRun': 'Última verificação {date}',
'wanderer.watch.neverRun': 'Ainda não verificado',
'wanderer.watch.paused': 'Pausado até {time}',
'wanderer.watch.pausedHint':
'O wanderer não respondeu. Depois de cada falha, o TREK espera mais antes de perguntar de novo.',
'wanderer.watch.blocked': 'Token recusado',
'wanderer.watch.blockedHint':
'O wanderer recusou o token da API. A verificação volta quando um teste de conexão passar ou um novo token for salvo.',
'wanderer.watch.hints': 'Avisos',
'wanderer.watch.hintsCount': '{count} avisos abertos',
'wanderer.watch.hintsCount.one': '{count} aviso aberto',
'wanderer.watch.empty': 'Nenhum aviso aberto',
'wanderer.watch.dialogTitle': 'Avisos sobre suas trilhas',
'wanderer.watch.openTour': 'Abrir o passeio',
'wanderer.watch.checkedAt': 'Verificado {date}',
'wanderer.watch.noticeLabel': 'Verificação em segundo plano',
'wanderer.watch.state.possible_change': 'Pode ter mudado',
'wanderer.watch.state.changed': 'Alterado',
'wanderer.watch.state.unverifiable': 'Não verificável',
'wanderer.watch.stateHint.possible_change':
'O wanderer mudou o carimbo de data da trilha. Só a verificação da fonte mostra se o conteúdo é diferente.',
'wanderer.watch.stateHint.changed':
'O registro do wanderer mostra outro nome ou outra descrição. A verificação da fonte mostra tudo o que mudou.',
'wanderer.watch.stateHint.unverifiable':
'O TREK não conseguiu avaliar esta trilha, então nada é dito sobre se ela mudou.',
'wanderer.watch.reason.unavailable': 'Trilha inacessível',
'wanderer.watch.reason.no_change_time': 'Sem carimbo de data',
};
export default wandererWatch;
@@ -75,6 +75,15 @@ const ca: NotificationLocale = {
title: 'Invitació a una col·lecció',
body: `${p.actor} t'ha convidat a compartir una col·lecció. Obre TREK per acceptar-la o rebutjar-la.`,
}),
wanderer_source_hint: (p) => ({
title: `Camí de wanderer: ${p.tour}`,
body:
p.state === 'changed'
? `«${p.tour}» a «${p.trip}» s'ha canviat a wanderer. Obre el recorregut i comprova'n la font per veure tots els canvis. Res del viatge canvia sol.`
: p.state === 'unverifiable'
? `«${p.tour}» a «${p.trip}» ja no es pot comprovar a wanderer. El recorregut del teu viatge es queda com està.`
: `«${p.tour}» a «${p.trip}» pot haver canviat a wanderer. Obre el recorregut i comprova'n la font per saber-ho. Res del viatge canvia sol.`,
}),
plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }),
},
passwordReset: {
+2
View File
@@ -55,6 +55,7 @@ import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import wanderer from './wanderer';
import wandererWatch from './wandererWatch';
const locale = {
...common,
@@ -66,6 +67,7 @@ const locale = {
...tourCollection,
...tourPacking,
...wanderer,
...wandererWatch,
...settings,
...admin,
...dayplan,
+5
View File
@@ -38,6 +38,11 @@ const notif: TranslationStrings = {
'notif.action.view_photos': 'Mostra les fotos',
'notif.action.view_vacay': 'Mostra Vacay',
'notif.action.view_admin': "Vés a l'administració",
'notif.wanderer_source_hint.title': 'Camí de wanderer',
'notif.wanderer_source_hint.possible_change': '«{tour}» a {trip} pot haver canviat a wanderer',
'notif.wanderer_source_hint.changed': "«{tour}» a {trip} s'ha canviat a wanderer",
'notif.wanderer_source_hint.unverifiable': '«{tour}» a {trip} ja no es pot comprovar a wanderer',
'notif.action.view_tour': 'Mostra el recorregut',
'notif.action.view': 'Mostra',
'notif.action.accept': 'Accepta',
'notif.action.decline': 'Rebutja',
+1
View File
@@ -87,6 +87,7 @@ const settings: TranslationStrings = {
'settings.notifyPhotosShared': 'Fotos compartides (Immich)',
'settings.notifyCollabMessage': 'Missatges de xat (Col·laboració)',
'settings.notifyPackingTagged': "Llista d'equipatge: assignacions",
'settings.notifyWandererSourceHint': 'wanderer: avisos sobre els teus camins',
'settings.notifyWebhook': 'Notificacions webhook',
'settings.notificationsDisabled':
'Les notificacions no estan configurades. Demana a un administrador que activi les notificacions per correu o webhook.',
+40
View File
@@ -0,0 +1,40 @@
import type { TranslationStrings } from '../types';
const wandererWatch: TranslationStrings = {
'wanderer.watch.label': 'Comprovar canvis en els meus camins',
'wanderer.watch.hint': 'Un avís quan un camí importat podria haver canviat.',
'wanderer.watch.info':
"Com a molt un cop al dia, TREK llegeix la fitxa breu de cada camí que has importat, mai el seu fitxer GPX. Només t'avisa: incorporar un canvi ho decideixes tu, amb la comprovació de la font del recorregut.",
'wanderer.watch.notConnected': 'Connecta primer wanderer',
'wanderer.watch.on': 'Comprovació en segon pla activada',
'wanderer.watch.off': 'Comprovació en segon pla desactivada',
'wanderer.watch.failed': "No s'ha pogut canviar la comprovació en segon pla",
'wanderer.watch.lastRun': 'Darrera comprovació {date}',
'wanderer.watch.neverRun': 'Encara sense comprovar',
'wanderer.watch.paused': 'En pausa fins a {time}',
'wanderer.watch.pausedHint':
'wanderer no ha respost. Després de cada error, TREK espera més abans de tornar a preguntar.',
'wanderer.watch.blocked': 'Testimoni rebutjat',
'wanderer.watch.blockedHint':
"wanderer ha rebutjat el testimoni de l'API. La comprovació es reprèn quan una prova de connexió funcioni o es desi un testimoni nou.",
'wanderer.watch.hints': 'Avisos',
'wanderer.watch.hintsCount': '{count} avisos oberts',
'wanderer.watch.hintsCount.one': '{count} avís obert',
'wanderer.watch.empty': 'Cap avís obert',
'wanderer.watch.dialogTitle': 'Avisos sobre els teus camins',
'wanderer.watch.openTour': 'Obre el recorregut',
'wanderer.watch.checkedAt': 'Comprovat {date}',
'wanderer.watch.noticeLabel': 'Comprovació en segon pla',
'wanderer.watch.state.possible_change': 'Pot haver canviat',
'wanderer.watch.state.changed': 'Canviat',
'wanderer.watch.state.unverifiable': 'No comprovable',
'wanderer.watch.stateHint.possible_change':
'wanderer ha canviat la marca de temps del camí. Només la comprovació de la font mostra si el contingut és diferent.',
'wanderer.watch.stateHint.changed':
'La fitxa de wanderer mostra un altre nom o una altra descripció. La comprovació de la font mostra tot el que ha canviat.',
'wanderer.watch.stateHint.unverifiable':
'TREK no ha pogut valorar aquest camí, així que no es diu res sobre si ha canviat.',
'wanderer.watch.reason.unavailable': 'Camí inaccessible',
'wanderer.watch.reason.no_change_time': 'Sense marca de temps',
};
export default wandererWatch;
@@ -81,6 +81,15 @@ const cs: NotificationLocale = {
title: 'Relace Synology byla zrušena',
body: 'Váš účet nebo URL Synology se změnil. Byli jste odhlášeni ze Synology Photos.',
}),
wanderer_source_hint: (p) => ({
title: `Trasa wanderer: ${p.tour}`,
body:
p.state === 'changed'
? `„${p.tour}“ v „${p.trip}“ byla ve wanderer změněna. Otevřete túru a zkontrolujte její zdroj, abyste viděli všechny změny. V cestě se nic nezmění samo.`
: p.state === 'unverifiable'
? `„${p.tour}“ v „${p.trip}“ už nelze ve wanderer ověřit. Túra ve vaší cestě zůstává, jak je.`
: `„${p.tour}“ v „${p.trip}“ se ve wanderer možná změnila. Otevřete túru a zkontrolujte její zdroj. V cestě se nic nezmění samo.`,
}),
plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }),
},
passwordReset: {
+2
View File
@@ -55,6 +55,7 @@ import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import wanderer from './wanderer';
import wandererWatch from './wandererWatch';
const locale = {
...common,
@@ -66,6 +67,7 @@ const locale = {
...tourCollection,
...tourPacking,
...wanderer,
...wandererWatch,
...settings,
...admin,
...dayplan,
+5
View File
@@ -42,6 +42,11 @@ const notif: TranslationStrings = {
'notif.action.view_vacay': 'Zobrazit Vacay',
'notif.action.view_collection': 'Zobrazit sbírku',
'notif.action.view_admin': 'Jít do adminu',
'notif.wanderer_source_hint.title': 'Trasa wanderer',
'notif.wanderer_source_hint.possible_change': '„{tour}“ v {trip} se ve wanderer možná změnila',
'notif.wanderer_source_hint.changed': '„{tour}“ v {trip} byla ve wanderer změněna',
'notif.wanderer_source_hint.unverifiable': '„{tour}“ v {trip} už nelze ve wanderer ověřit',
'notif.action.view_tour': 'Zobrazit túru',
'notif.action.view': 'Zobrazit',
'notif.action.accept': 'Přijmout',
'notif.action.decline': 'Odmítnout',
+1
View File
@@ -101,6 +101,7 @@ const settings: TranslationStrings = {
'settings.notifyPhotosShared': 'Sdílené fotky (Immich)',
'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)',
'settings.notifyPackingTagged': 'Seznam balení: přiřazení',
'settings.notifyWandererSourceHint': 'wanderer: upozornění k vašim trasám',
'settings.notifyWebhook': 'Webhook oznámení',
'settings.notificationsDisabled':
'Oznámení nejsou nakonfigurována. Požádejte správce o aktivaci e-mailových nebo webhookových oznámení.',
+39
View File
@@ -0,0 +1,39 @@
import type { TranslationStrings } from '../types';
const wandererWatch: TranslationStrings = {
'wanderer.watch.label': 'Kontrolovat změny mých tras',
'wanderer.watch.hint': 'Upozornění, když se importovaná trasa mohla změnit.',
'wanderer.watch.info':
'Nejvýše jednou denně TREK přečte krátký záznam každé trasy, kterou jste importovali, nikdy její soubor GPX. Jen vás upozorní: zda změnu převezmete, rozhodnete sami kontrolou zdroje túry.',
'wanderer.watch.notConnected': 'Nejdřív připojte wanderer',
'wanderer.watch.on': 'Kontrola na pozadí zapnuta',
'wanderer.watch.off': 'Kontrola na pozadí vypnuta',
'wanderer.watch.failed': 'Kontrolu na pozadí se nepodařilo přepnout',
'wanderer.watch.lastRun': 'Naposledy zkontrolováno {date}',
'wanderer.watch.neverRun': 'Zatím nezkontrolováno',
'wanderer.watch.paused': 'Pozastaveno do {time}',
'wanderer.watch.pausedHint': 'wanderer neodpověděl. Po každém nezdaru TREK čeká déle, než se zeptá znovu.',
'wanderer.watch.blocked': 'Token odmítnut',
'wanderer.watch.blockedHint':
'wanderer odmítl token API. Kontrola pokračuje, jakmile projde test připojení nebo se uloží nový token.',
'wanderer.watch.hints': 'Upozornění',
'wanderer.watch.hintsCount': '{count} otevřených upozornění',
'wanderer.watch.hintsCount.one': '{count} otevřené upozornění',
'wanderer.watch.hintsCount.few': '{count} otevřená upozornění',
'wanderer.watch.empty': 'Žádná otevřená upozornění',
'wanderer.watch.dialogTitle': 'Upozornění k vašim trasám',
'wanderer.watch.openTour': 'Otevřít túru',
'wanderer.watch.checkedAt': 'Zkontrolováno {date}',
'wanderer.watch.noticeLabel': 'Kontrola na pozadí',
'wanderer.watch.state.possible_change': 'Možná změněno',
'wanderer.watch.state.changed': 'Změněno',
'wanderer.watch.state.unverifiable': 'Nelze ověřit',
'wanderer.watch.stateHint.possible_change':
'wanderer změnil časové razítko trasy. Jen kontrola zdroje ukáže, zda se liší obsah.',
'wanderer.watch.stateHint.changed':
'Záznam ve wanderer ukazuje jiný název nebo popis. Kontrola zdroje ukáže vše, co se změnilo.',
'wanderer.watch.stateHint.unverifiable': 'TREK tuto trasu nemohl posoudit, takže se o změně nic neříká.',
'wanderer.watch.reason.unavailable': 'Trasa není dostupná',
'wanderer.watch.reason.no_change_time': 'Bez časového razítka',
};
export default wandererWatch;
@@ -75,6 +75,15 @@ const de: NotificationLocale = {
title: 'Synology-Sitzung beendet',
body: 'Dein Synology-Konto oder die URL hat sich geändert. Du wurdest von Synology Photos abgemeldet.',
}),
wanderer_source_hint: (p) => ({
title: `wanderer-Trail: ${p.tour}`,
body:
p.state === 'changed'
? `„${p.tour}“ in „${p.trip}“ wurde in wanderer geändert. Öffne die Tour und prüfe ihre Quelle, um alle Änderungen zu sehen. In der Reise ändert sich nichts von selbst.`
: p.state === 'unverifiable'
? `„${p.tour}“ in „${p.trip}“ lässt sich in wanderer nicht mehr prüfen. Die Tour in deiner Reise bleibt, wie sie ist.`
: `„${p.tour}“ in „${p.trip}“ hat sich in wanderer möglicherweise geändert. Öffne die Tour und prüfe ihre Quelle, um es zu sehen. In der Reise ändert sich nichts von selbst.`,
}),
plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }),
},
passwordReset: {
+2
View File
@@ -55,6 +55,7 @@ import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import wanderer from './wanderer';
import wandererWatch from './wandererWatch';
const locale = {
...common,
@@ -66,6 +67,7 @@ const locale = {
...tourCollection,
...tourPacking,
...wanderer,
...wandererWatch,
...settings,
...admin,
...dayplan,
+5
View File
@@ -41,6 +41,11 @@ const notif: TranslationStrings = {
'notif.action.view_vacay': 'Vacay ansehen',
'notif.action.view_collection': 'Sammlung ansehen',
'notif.action.view_admin': 'Zum Admin',
'notif.wanderer_source_hint.title': 'wanderer-Trail',
'notif.wanderer_source_hint.possible_change': '„{tour}“ in {trip} hat sich in wanderer möglicherweise geändert',
'notif.wanderer_source_hint.changed': '„{tour}“ in {trip} wurde in wanderer geändert',
'notif.wanderer_source_hint.unverifiable': '„{tour}“ in {trip} lässt sich in wanderer nicht mehr prüfen',
'notif.action.view_tour': 'Tour ansehen',
'notif.action.view': 'Ansehen',
'notif.action.accept': 'Annehmen',
'notif.action.decline': 'Ablehnen',
+1
View File
@@ -105,6 +105,7 @@ const settings: TranslationStrings = {
'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)',
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
'settings.notifyPackingTagged': 'Packliste: Zuweisungen',
'settings.notifyWandererSourceHint': 'wanderer: Hinweise zu deinen Trails',
'settings.notifyWebhook': 'Webhook-Benachrichtigungen',
'settings.notificationsDisabled':
'Benachrichtigungen sind nicht konfiguriert. Bitten Sie einen Administrator, E-Mail- oder Webhook-Benachrichtungen zu aktivieren.',
+40
View File
@@ -0,0 +1,40 @@
import type { TranslationStrings } from '../types';
const wandererWatch: TranslationStrings = {
'wanderer.watch.label': 'Meine Trails auf Änderungen prüfen',
'wanderer.watch.hint': 'Eine Benachrichtigung, wenn sich ein importierter Trail geändert haben könnte.',
'wanderer.watch.info':
'Höchstens einmal am Tag liest TREK den kurzen Eintrag jedes importierten Trails, nie seine GPX-Datei. Es benachrichtigt dich nur: Ob eine Änderung übernommen wird, entscheidest du selbst über die Quellenprüfung der Tour.',
'wanderer.watch.notConnected': 'Verbinde zuerst wanderer',
'wanderer.watch.on': 'Hintergrundprüfung eingeschaltet',
'wanderer.watch.off': 'Hintergrundprüfung ausgeschaltet',
'wanderer.watch.failed': 'Die Hintergrundprüfung konnte nicht umgeschaltet werden',
'wanderer.watch.lastRun': 'Zuletzt geprüft {date}',
'wanderer.watch.neverRun': 'Noch nicht geprüft',
'wanderer.watch.paused': 'Pausiert bis {time}',
'wanderer.watch.pausedHint':
'wanderer hat nicht geantwortet. Nach jedem Fehlschlag wartet TREK länger, bevor es wieder fragt.',
'wanderer.watch.blocked': 'Token abgelehnt',
'wanderer.watch.blockedHint':
'wanderer hat das API-Token abgelehnt. Die Prüfung läuft weiter, sobald ein Verbindungstest gelingt oder ein neues Token gespeichert ist.',
'wanderer.watch.hints': 'Hinweise',
'wanderer.watch.hintsCount': '{count} offene Hinweise',
'wanderer.watch.hintsCount.one': '{count} offener Hinweis',
'wanderer.watch.empty': 'Keine offenen Hinweise',
'wanderer.watch.dialogTitle': 'Hinweise zu deinen Trails',
'wanderer.watch.openTour': 'Tour öffnen',
'wanderer.watch.checkedAt': 'Geprüft {date}',
'wanderer.watch.noticeLabel': 'Hintergrundprüfung',
'wanderer.watch.state.possible_change': 'Möglicherweise geändert',
'wanderer.watch.state.changed': 'Geändert',
'wanderer.watch.state.unverifiable': 'Nicht prüfbar',
'wanderer.watch.stateHint.possible_change':
'wanderer hat den Zeitstempel des Trails geändert. Ob sich der Inhalt unterscheidet, zeigt erst die Quellenprüfung.',
'wanderer.watch.stateHint.changed':
'Der Eintrag in wanderer hat einen anderen Namen oder eine andere Beschreibung. Was sich alles geändert hat, zeigt die Quellenprüfung.',
'wanderer.watch.stateHint.unverifiable':
'TREK konnte diesen Trail nicht beurteilen. Ob er sich geändert hat, bleibt offen.',
'wanderer.watch.reason.unavailable': 'Trail nicht erreichbar',
'wanderer.watch.reason.no_change_time': 'Kein Zeitstempel',
};
export default wandererWatch;
@@ -75,6 +75,15 @@ const en: NotificationLocale = {
title: 'Synology session cleared',
body: 'Your Synology account or URL changed. You have been logged out of Synology Photos.',
}),
wanderer_source_hint: (p) => ({
title: `wanderer trail: ${p.tour}`,
body:
p.state === 'changed'
? `"${p.tour}" in "${p.trip}" was changed in wanderer. Open the tour and check its source to see everything that changed. Nothing in the trip changes by itself.`
: p.state === 'unverifiable'
? `"${p.tour}" in "${p.trip}" can no longer be checked in wanderer. The tour in your trip stays as it is.`
: `"${p.tour}" in "${p.trip}" may have changed in wanderer. Open the tour and check its source to see whether it did. Nothing in the trip changes by itself.`,
}),
plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }),
},
passwordReset: {
+2
View File
@@ -55,6 +55,7 @@ import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import wanderer from './wanderer';
import wandererWatch from './wandererWatch';
const locale = {
...common,
@@ -66,6 +67,7 @@ const locale = {
...tourCollection,
...tourPacking,
...wanderer,
...wandererWatch,
...settings,
...admin,
...dayplan,
+5
View File
@@ -41,6 +41,11 @@ const notif: TranslationStrings = {
'notif.action.view_vacay': 'View Vacay',
'notif.action.view_collection': 'View Collection',
'notif.action.view_admin': 'Go to Admin',
'notif.wanderer_source_hint.title': 'wanderer trail',
'notif.wanderer_source_hint.possible_change': '“{tour}” in {trip} may have changed in wanderer',
'notif.wanderer_source_hint.changed': '“{tour}” in {trip} was changed in wanderer',
'notif.wanderer_source_hint.unverifiable': '“{tour}” in {trip} can no longer be checked in wanderer',
'notif.action.view_tour': 'View Tour',
'notif.action.view': 'View',
'notif.action.accept': 'Accept',
'notif.action.decline': 'Decline',
+1
View File
@@ -115,6 +115,7 @@ const settings: TranslationStrings = {
'settings.notifyPhotosShared': 'Shared photos (Immich)',
'settings.notifyCollabMessage': 'Chat messages (Collab)',
'settings.notifyPackingTagged': 'Packing list: assignments',
'settings.notifyWandererSourceHint': 'wanderer: hints on your trails',
'settings.notifyWebhook': 'Webhook notifications',
'settings.notifyVersionAvailable': 'New version available',
'settings.notifyReplicaFailure': 'Storage replica failure',
+39
View File
@@ -0,0 +1,39 @@
import type { TranslationStrings } from '../types';
const wandererWatch: TranslationStrings = {
'wanderer.watch.label': 'Check my trails for changes',
'wanderer.watch.hint': 'A notification when an imported trail may have changed.',
'wanderer.watch.info':
'At most once a day, TREK reads the short record of each trail you imported, never its GPX file. It only notifies you: whether a change comes in is up to you, through the source check of the tour.',
'wanderer.watch.notConnected': 'Connect wanderer first',
'wanderer.watch.on': 'Background check switched on',
'wanderer.watch.off': 'Background check switched off',
'wanderer.watch.failed': 'The background check could not be switched',
'wanderer.watch.lastRun': 'Last looked {date}',
'wanderer.watch.neverRun': 'Not looked yet',
'wanderer.watch.paused': 'Paused until {time}',
'wanderer.watch.pausedHint': 'wanderer did not answer. TREK waits longer after each failure before it asks again.',
'wanderer.watch.blocked': 'Token refused',
'wanderer.watch.blockedHint':
'wanderer refused the API token. The check resumes once a connection test passes or a new token is saved.',
'wanderer.watch.hints': 'Hints',
'wanderer.watch.hintsCount': '{count} open hints',
'wanderer.watch.hintsCount.one': '{count} open hint',
'wanderer.watch.empty': 'No open hints',
'wanderer.watch.dialogTitle': 'Hints on your trails',
'wanderer.watch.openTour': 'Open the tour',
'wanderer.watch.checkedAt': 'Looked {date}',
'wanderer.watch.noticeLabel': 'Background check',
'wanderer.watch.state.possible_change': 'May have changed',
'wanderer.watch.state.changed': 'Changed',
'wanderer.watch.state.unverifiable': 'Not checkable',
'wanderer.watch.stateHint.possible_change':
"wanderer changed the trail's time stamp. Only the source check shows whether its content differs.",
'wanderer.watch.stateHint.changed':
"wanderer's record shows another name or description. The source check shows everything that changed.",
'wanderer.watch.stateHint.unverifiable':
'TREK could not judge this trail, so nothing is said about whether it changed.',
'wanderer.watch.reason.unavailable': 'Trail out of reach',
'wanderer.watch.reason.no_change_time': 'No time stamp',
};
export default wandererWatch;
@@ -75,6 +75,15 @@ const es: NotificationLocale = {
title: 'Sesión de Synology cerrada',
body: 'Tu cuenta o URL de Synology ha cambiado. Has cerrado sesión en Synology Photos.',
}),
wanderer_source_hint: (p) => ({
title: `Ruta de wanderer: ${p.tour}`,
body:
p.state === 'changed'
? `«${p.tour}» en «${p.trip}» se cambió en wanderer. Abre el recorrido y comprueba su fuente para ver todos los cambios. Nada del viaje cambia por sí solo.`
: p.state === 'unverifiable'
? `«${p.tour}» en «${p.trip}» ya no se puede comprobar en wanderer. El recorrido de tu viaje se queda como está.`
: `«${p.tour}» en «${p.trip}» puede haber cambiado en wanderer. Abre el recorrido y comprueba su fuente para saberlo. Nada del viaje cambia por sí solo.`,
}),
plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }),
},
passwordReset: {
+2
View File
@@ -55,6 +55,7 @@ import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import wanderer from './wanderer';
import wandererWatch from './wandererWatch';
const locale = {
...common,
@@ -66,6 +67,7 @@ const locale = {
...tourCollection,
...tourPacking,
...wanderer,
...wandererWatch,
...settings,
...admin,
...dayplan,
+5
View File
@@ -41,6 +41,11 @@ const notif: TranslationStrings = {
'notif.action.view_vacay': 'Ver Vacay',
'notif.action.view_collection': 'Ver colección',
'notif.action.view_admin': 'Ir al admin',
'notif.wanderer_source_hint.title': 'Ruta de wanderer',
'notif.wanderer_source_hint.possible_change': '«{tour}» en {trip} puede haber cambiado en wanderer',
'notif.wanderer_source_hint.changed': '«{tour}» en {trip} se cambió en wanderer',
'notif.wanderer_source_hint.unverifiable': '«{tour}» en {trip} ya no se puede comprobar en wanderer',
'notif.action.view_tour': 'Ver recorrido',
'notif.action.view': 'Ver',
'notif.action.accept': 'Aceptar',
'notif.action.decline': 'Rechazar',

Some files were not shown because too many files have changed in this diff Show More