fix(tours): refresh the wanderer watch after a fixed token and truly disable its switch offline

The watch row only re-read its state when the connection came or went, so after
a passing connection test or a newly saved token it kept saying "Token refused"
until a reload. The connection card now hands it a key built from the saved
connection and its last check, and the row re-reads the watch whenever that
changes.

Without a connection the switch was only dimmed: the button stayed in the tab
order and was announced as active. ToggleSwitch gains a disabled prop that
disables the button and marks it aria-disabled, and the reason stays reachable
through a focusable wrapper carrying the tooltip.
This commit is contained in:
Maurice
2026-10-20 14:46:24 +02:00
parent 2e1dca9fe9
commit fbba4fd73b
8 changed files with 85 additions and 24 deletions
@@ -64,4 +64,26 @@ describe('ToggleSwitch', () => {
rerender(<ToggleSwitch on={true} onToggle={() => {}} />);
expect(button.querySelector('span')!.style.left).toBe('22px');
});
it('FE-COMP-TOGGLESWITCH-009: a disabled switch is out of reach and says so', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(<ToggleSwitch on={false} onToggle={onToggle} disabled />);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-disabled', 'true');
expect(button.style.cursor).toBe('not-allowed');
await user.click(button);
await user.tab();
expect(onToggle).not.toHaveBeenCalled();
expect(button).not.toHaveFocus();
});
it('FE-COMP-TOGGLESWITCH-010: an enabled switch keeps the pointer and no aria-disabled', () => {
render(<ToggleSwitch on={false} onToggle={() => {}} />);
const button = screen.getByRole('button');
expect(button).toBeEnabled();
expect(button).not.toHaveAttribute('aria-disabled');
expect(button.style.cursor).toBe('pointer');
});
});
@@ -5,16 +5,18 @@ const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
/**
* `describedBy` names the element that explains the switch, for a switch whose label alone
* does not say what flipping it changes.
* does not say what flipping it changes. `disabled` takes it out of the tab order and tells
* assistive tech it cannot act; the caller dims it and says why.
*/
export default function ToggleSwitch({ on, onToggle, label, describedBy }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string }) {
export default function ToggleSwitch({ on, onToggle, label, describedBy, disabled = false }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string; disabled?: boolean }) {
return (
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
disabled={disabled} aria-disabled={disabled || undefined}
// A keyboard focus ring in the text colour, like the fields; the pointer gets none.
className="group outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
style={{
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
borderRadius: 12, border: 'none', padding: 0, cursor: disabled ? 'not-allowed' : 'pointer',
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
boxShadow: 'inset 0 1px 2px color-mix(in srgb, var(--text-primary) 12%, transparent)',
transition: `background 0.2s ${EASE}`,
@@ -140,7 +140,7 @@ export default function WandererConnectionSection(): React.ReactElement {
</div>
)}
<WandererWatchRow connected={S.connected} />
<WandererWatchRow refreshKey={S.checkKey} />
</Section>
);
}
@@ -54,20 +54,23 @@ beforeEach(() => {
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} />);
render(<WandererWatchRow refreshKey="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(toggle).toBeDisabled();
expect(toggle).toHaveAttribute('aria-disabled', 'true');
await userEvent.tab();
expect(toggle).not.toHaveFocus();
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 />);
render(<WandererWatchRow refreshKey="true||" />);
const toggle = await screen.findByRole('button', { name: 'Check my trails for changes', pressed: false });
await waitFor(() => expect(toggle.closest('[aria-disabled="true"]')).toBeNull());
await waitFor(() => expect(toggle).toBeEnabled());
await userEvent.click(toggle);
expect(repo.setWatch).toHaveBeenCalledWith(true);
expect(await screen.findByText('Not looked yet')).toBeInTheDocument();
@@ -76,7 +79,7 @@ describe('WandererWatchRow (WAN-02)', () => {
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 />);
render(<WandererWatchRow refreshKey="true||" />);
expect(await screen.findByTestId('wanderer-hint-count')).toHaveTextContent('2');
await userEvent.click(screen.getByRole('button', { name: /Hints/ }));
@@ -95,7 +98,7 @@ describe('WandererWatchRow (WAN-02)', () => {
it('names a refused token instead of a last look', async () => {
repo.getWatch.mockResolvedValue({ ...off, enabled: true, blocked: true });
render(<WandererWatchRow connected />);
render(<WandererWatchRow refreshKey="true||" />);
expect(await screen.findByText('Token refused')).toBeInTheDocument();
});
});
@@ -13,12 +13,22 @@ import { useWandererWatch, type WandererWatchController } from './useWandererWat
function WatchSwitch({ W }: { W: WandererWatchController }) {
const { t } = useTranslation();
const toggle = (
<ToggleSwitch on={W.enabled} onToggle={() => void W.toggle()} label={t('wanderer.watch.label')} />
<ToggleSwitch
on={W.enabled}
onToggle={() => void W.toggle()}
label={t('wanderer.watch.label')}
disabled={!W.available}
/>
);
if (W.available) return toggle;
// A disabled button takes no focus and, in some browsers, no hover either, so
// the reason sits on a focusable wrapper the tooltip listens to.
return (
<Tooltip label={t('wanderer.watch.notConnected')}>
<span className="inline-flex opacity-50" aria-disabled="true">
<span
tabIndex={0}
className="inline-flex cursor-not-allowed rounded-full opacity-50 outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
>
<span className="pointer-events-none inline-flex">{toggle}</span>
</span>
</Tooltip>
@@ -62,9 +72,9 @@ function HintsButton({ W }: { W: WandererWatchController }) {
* 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 {
export default function WandererWatchRow({ refreshKey }: { refreshKey: string }): React.ReactElement {
const { t } = useTranslation();
const W = useWandererWatch(connected);
const W = useWandererWatch(refreshKey);
return (
<SettingRows>
<SettingRow
@@ -129,6 +129,13 @@ export function useWandererConnection() {
saved?.lastCheckedAt && saved.lastCheckOk === false && saved.lastCheckError
? errorText(saved.lastCheckError)
: null;
/**
* Changes whenever the server's view of the connection does: connecting,
* disconnecting, a saved token (which clears the last check) or a test that
* stored a new result. The watch row re-reads itself on it, since each of
* those can lift or set its "token refused" state.
*/
const checkKey = `${connected}|${saved?.lastCheckedAt ?? ''}|${saved?.lastCheckOk ?? ''}`;
return {
url,
@@ -145,6 +152,7 @@ export function useWandererConnection() {
tokenInvalid,
canSave,
lastCheck,
checkKey,
save,
test,
disconnect,
@@ -75,18 +75,32 @@ describe('watchStatusOf (WAN-02)', () => {
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 },
const { result, rerender } = renderHook(({ refreshKey }) => useWandererWatch(refreshKey), {
initialProps: { refreshKey: 'true||' },
});
await waitFor(() => expect(result.current.available).toBe(true));
expect(result.current.enabled).toBe(false);
expect(result.current.status).toBeNull();
rerender({ connected: false });
rerender({ refreshKey: 'true||' });
expect(repo.getWatch).toHaveBeenCalledTimes(1);
rerender({ refreshKey: 'false||' });
await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2));
});
it('drops a refused token once a connection test passes, without a reload', async () => {
repo.getWatch.mockResolvedValueOnce({ ...off, enabled: true, blocked: true, pausedUntil: '2999-01-01T00:00:00Z' });
repo.getWatch.mockResolvedValueOnce({ ...off, enabled: true, lastRunAt: '2026-10-20T10:00:00Z' });
const { result, rerender } = renderHook(({ refreshKey }) => useWandererWatch(refreshKey), {
initialProps: { refreshKey: 'true|2026-10-20T09:00:00Z|false' },
});
await waitFor(() => expect(result.current.status?.text).toBe('wanderer.watch.blocked'));
rerender({ refreshKey: 'true|2026-10-20T11:00:00Z|true' });
await waitFor(() => expect(result.current.status?.tone).toBe('neutral'));
expect(result.current.status?.text).toMatch(/^wanderer\.watch\.lastRun/);
});
it('switches at once, and says so either way', async () => {
const { result } = renderHook(() => useWandererWatch(true));
const { result } = renderHook(() => useWandererWatch('true||'));
await waitFor(() => expect(result.current.available).toBe(true));
await act(async () => {
await result.current.toggle();
@@ -105,7 +119,7 @@ describe('useWandererWatch (WAN-02)', () => {
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));
const { result } = renderHook(() => useWandererWatch('false||'));
await waitFor(() => expect(repo.getWatch).toHaveBeenCalled());
await act(async () => {
await result.current.toggle();
@@ -113,14 +127,14 @@ describe('useWandererWatch (WAN-02)', () => {
expect(repo.setWatch).not.toHaveBeenCalled();
repo.getWatch.mockRejectedValueOnce(new Error('offline'));
const failed = renderHook(() => useWandererWatch(true));
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));
const { result } = renderHook(() => useWandererWatch('true||'));
await waitFor(() => expect(result.current.hints).toEqual([hint]));
act(() => result.current.openHints());
expect(result.current.hintsOpen).toBe(true);
@@ -43,12 +43,14 @@ export function watchStatusOf(
/**
* 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
* and again whenever `refreshKey` changes, which the connection card bumps on
* connecting, disconnecting, saving a token and a stored test result, so a
* refused token clears once it is fixed. It 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) {
export function useWandererWatch(refreshKey: string) {
const { t, locale } = useTranslation();
const toast = useToast();
const navigate = useNavigate();
@@ -71,7 +73,7 @@ export function useWandererWatch(connected: boolean) {
return () => {
cancelled = true;
};
}, [connected]);
}, [refreshKey]);
const toggle = useCallback(async () => {
if (!watch?.connected || saving) return;