mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-05 20:15:59 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d589da59f2 | |||
| 69ff295184 | |||
| d07c17ded6 | |||
| 44509723fd | |||
| b80fdf27f2 | |||
| a0b7b4d196 | |||
| aa24a9eff7 | |||
| de5417f9f0 | |||
| a48dd7645f | |||
| 217a576e16 | |||
| f6976d5cec | |||
| c1c2bbf146 | |||
| c05349f7c4 | |||
| a5342df648 | |||
| f44b143371 | |||
| d911122a37 | |||
| a69eee191c | |||
| 90f31d3f07 | |||
| d4da05bbc9 | |||
| bd1de9e1d5 | |||
| c33eb5b593 | |||
| dcfbf89234 | |||
| 6b6d971f07 | |||
| 9fc98b5d49 | |||
| daaa6c425d | |||
| b33ef6bec2 | |||
| eb9f6a3438 | |||
| 7823ab1550 | |||
| 47fdd3588c | |||
| a9ebd2d1ac | |||
| 9c32b42256 | |||
| 26611b46e4 | |||
| e3eec8d6d1 | |||
| 2d398568da | |||
| 4051af58a7 | |||
| 3681db6cd4 | |||
| 44cefc2fe2 | |||
| 176bfaefe0 | |||
| 2372dde716 | |||
| 42ed858853 |
@@ -1161,7 +1161,7 @@ jobs:
|
||||
docker run -d --name trek-ci -p 127.0.0.1:3000:3000 \
|
||||
--read-only --tmpfs /tmp:noexec,nosuid,size=128m \
|
||||
--security-opt no-new-privileges:true \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add KILL \
|
||||
-v "$RUNNER_TEMP/trek/data:/app/data" \
|
||||
-v "$RUNNER_TEMP/trek/uploads:/app/uploads" \
|
||||
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
||||
@@ -1194,6 +1194,17 @@ jobs:
|
||||
curl -sf http://127.0.0.1:3000/ | grep -q '<div id="root">' \
|
||||
|| { echo "::error::/ does not serve the client"; exit 1; }
|
||||
|
||||
# Same capabilities as the shipped compose file: dumb-init has to be able
|
||||
# to pass the stop signal on to node, or docker stop ends in SIGKILL (137).
|
||||
- name: Stop gracefully
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker stop -t 30 trek-ci >/dev/null
|
||||
code="$(docker inspect -f '{{.State.ExitCode}}' trek-ci)"
|
||||
[ "$code" = "0" ] || { echo "::error::docker stop ended with exit code $code, not a graceful shutdown"; exit 1; }
|
||||
docker logs trek-ci 2>&1 | grep -q 'SIGTERM received' \
|
||||
|| { echo "::error::the server never saw SIGTERM"; exit 1; }
|
||||
|
||||
- name: Container log
|
||||
if: always()
|
||||
run: docker logs trek-ci 2>&1 || true
|
||||
|
||||
@@ -234,12 +234,15 @@ the details.
|
||||
## Get started in 30 seconds
|
||||
|
||||
```bash
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d --name trek -p 3000:3000 \
|
||||
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||
-e COOKIE_SECURE=false \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
|
||||
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.3.4
|
||||
version: 5.0.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.3.4"
|
||||
appVersion: "5.0.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-non-null-assertion": 382,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 221,
|
||||
"complexity": 220,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
@@ -20,7 +20,7 @@
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
"react-refresh/only-export-components": 45,
|
||||
"react-refresh/only-export-components": 44,
|
||||
"unused-disable-directive": 6,
|
||||
"max-depth": 21,
|
||||
"react-hooks/void-use-memo": 1,
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
},
|
||||
{
|
||||
"feature": "AI assistant chat",
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts", "src/components/Assistant/useAssistantStatus.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
|
||||
},
|
||||
|
||||
@@ -150,7 +150,6 @@
|
||||
"src/pages/resetPassword/useResetPassword.ts",
|
||||
"src/pages/settings/useSettings.ts",
|
||||
"src/pages/sharedTrip/useSharedTrip.ts",
|
||||
"src/pages/tripPlanner/useBookingEdits.ts",
|
||||
"src/pages/tripPlanner/usePlaceFormOpeners.ts",
|
||||
"src/pages/tripPlanner/usePlannerDialogs.ts",
|
||||
"src/pages/tripPlanner/useTripPlanner.ts",
|
||||
|
||||
@@ -59,8 +59,6 @@
|
||||
"components/Studio/StudioSavedLayouts.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 4,
|
||||
"components/Studio/TravelPreview.tsx": 1,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
|
||||
"components/SystemNotices/releaseNotice.css": 2,
|
||||
"components/Vacay/VacayCalendar.tsx": 3,
|
||||
"components/Vacay/VacayMonthCard.tsx": 7,
|
||||
"components/Vacay/VacaySettings.tsx": 1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"components/Collections/CollectionMap.tsx": 1,
|
||||
"components/Studio/LineHandles.tsx": 1,
|
||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||
"components/Studio/inspector/FocalPad.tsx": 1,
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1399,
|
||||
"src/components/Map/MapView.tsx": 1385,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/RouteCalculator.ts": 1076,
|
||||
"src/components/Map/RouteCalculator.ts": 1063,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
"components/Studio/TravelElements.tsx": 5,
|
||||
"components/Studio/printSheets.ts": 5,
|
||||
"components/Studio/sidebar/contentDrops.ts": 2,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
|
||||
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
|
||||
"components/SystemNotices/SystemNoticeModal.tsx": 107,
|
||||
"components/Todo/TodoListPanel.tsx": 6,
|
||||
|
||||
@@ -229,6 +229,8 @@ export const aiAdminApi = {
|
||||
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
|
||||
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
|
||||
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
|
||||
setModel: (model: string): Promise<AiAdminConnectionResponse> =>
|
||||
apiClient.patch('/admin/ai/connection/model', { model }).then((r) => r.data),
|
||||
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
|
||||
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, I
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/documentProviderIconMap'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function AddonSubRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
@@ -26,6 +27,8 @@ export default function AddonSubRow({
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
/** A small pill after the title, e.g. the beta mark on the assistant. */
|
||||
badge?: ReactNode
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
@@ -34,8 +37,11 @@ export default function AddonSubRow({
|
||||
{icon
|
||||
? <span className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}>{icon}</span>
|
||||
: <span className="w-6 flex-none" aria-hidden />}
|
||||
<span className={`min-w-0 flex-1 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
|
||||
{title}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className={`min-w-0 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
|
||||
{title}
|
||||
</span>
|
||||
{badge}
|
||||
</span>
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
|
||||
</li>
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function AiAddonTile({
|
||||
icon={<Icon size={14} />}
|
||||
title={label.name}
|
||||
description={label.description}
|
||||
badge={addon.id === 'ai_assistant' ? <BetaBadge label={t('assistant.setup.beta')} /> : undefined}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => onToggle(addon)}
|
||||
/>
|
||||
@@ -93,3 +94,16 @@ export default function AiAddonTile({
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** The assistant is still young; its switch says so with a small accent pill. */
|
||||
function BetaBadge({ label }: { label: string }): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full bg-accent px-[7px] py-[2px] font-geist font-bold uppercase leading-none tracking-[.08em] text-accent-text shadow-sm"
|
||||
style={fs(9)}
|
||||
>
|
||||
<Sparkles size={9} strokeWidth={2.6} aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { openrouterReturnCode } from '../../Settings/ai/aiConnectModel';
|
||||
import type { Addon } from './addonModel';
|
||||
import { usesConnection } from './aiAddons';
|
||||
|
||||
@@ -11,7 +12,7 @@ import { usesConnection } from './aiAddons';
|
||||
*/
|
||||
export function useAiAddonTile(aiAddons: Addon[]) {
|
||||
const [params] = useSearchParams();
|
||||
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
|
||||
const [open, setOpen] = useState(() => openrouterReturnCode(params) !== undefined);
|
||||
const canSetUp = usesConnection(aiAddons);
|
||||
return {
|
||||
enabledCount: aiAddons.filter((a) => a.enabled).length,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone.
|
||||
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-008: the AI assistant's settings and connection for everyone.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
|
||||
import { MASKED_SETTING_VALUE } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
@@ -14,6 +16,7 @@ const repo = vi.hoisted(() => ({
|
||||
saveAdminSettings: vi.fn(),
|
||||
adminConnection: vi.fn(),
|
||||
saveAdminConnection: vi.fn(),
|
||||
setAdminModel: vi.fn(),
|
||||
removeAdminConnection: vi.fn(),
|
||||
adminModels: vi.fn(),
|
||||
}));
|
||||
@@ -59,6 +62,8 @@ async function renderLoaded() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
// The settings show while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.status.mockRejectedValue(new Error('not needed'));
|
||||
repo.adminSettings.mockResolvedValue(SETTINGS);
|
||||
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
||||
@@ -153,6 +158,29 @@ describe('useAiAdminConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("FE-HOOK-AI-ADMIN-008: a shared ChatGPT plan's model is switched on its own, and the answer shows (#2783)", async () => {
|
||||
const plan = (model: string): AiAdminConnectionResponse => ({
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'chatgpt',
|
||||
model,
|
||||
hint: null,
|
||||
status: 'ok',
|
||||
host: 'api.openai.com',
|
||||
baseUrl: null,
|
||||
},
|
||||
prefill: null,
|
||||
});
|
||||
repo.adminConnection.mockResolvedValue(plan('gpt-6.1-sol'));
|
||||
repo.setAdminModel.mockResolvedValue(plan('gpt-6.1-sol-mini'));
|
||||
const { result } = await renderLoaded();
|
||||
await act(async () => result.current.instance.setModel('gpt-6.1-sol-mini'));
|
||||
await waitFor(() => expect(repo.setAdminModel).toHaveBeenCalledWith('gpt-6.1-sol-mini'));
|
||||
expect(repo.saveAdminConnection).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(result.current.instance.live?.model).toBe('gpt-6.1-sol-mini'));
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => {
|
||||
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE);
|
||||
const { result } = await renderLoaded();
|
||||
|
||||
@@ -124,9 +124,16 @@ function useInstanceConnection(onChanged: () => void) {
|
||||
}
|
||||
};
|
||||
|
||||
/** Another model on the same connection: the stored key is kept by sending the mask in its place. */
|
||||
/**
|
||||
* Another model on the same connection: the stored key is kept by sending the mask in its place.
|
||||
* A shared ChatGPT plan has a sign-in, not a key, so its model is switched on its own (#2783).
|
||||
*/
|
||||
const setModel = (model: string) => {
|
||||
if (!live || live.kind === 'chatgpt') return;
|
||||
if (!live) return;
|
||||
if (live.kind === 'chatgpt') {
|
||||
void act(() => aiConnectionsRepo.setAdminModel(model), 'common.saved');
|
||||
return;
|
||||
}
|
||||
const request: AiInstanceConnectionRequest =
|
||||
live.kind === 'custom'
|
||||
? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) }
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { AssistantChatTrip } from './assistantViewContext';
|
||||
import type { EntityTarget } from './cards/cardTypes';
|
||||
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
|
||||
import { useAssistantHotkey } from './useAssistantHotkey';
|
||||
import { useAssistantStatus } from './useAssistantStatus';
|
||||
import { useAssistantThread } from './useAssistantThread';
|
||||
|
||||
/**
|
||||
@@ -202,15 +203,12 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open);
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const toggle = useAssistantStore((s) => s.toggle);
|
||||
const [picked, setPicked] = useState<WindowTrip | null>(null);
|
||||
const [wasOpen, setWasOpen] = useState(open);
|
||||
const home = useAssistantHome(setPicked);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
useAssistantStatus();
|
||||
useAssistantHotkey(available, toggle);
|
||||
|
||||
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* FE-AI-STATUS-001 to FE-AI-STATUS-003
|
||||
*
|
||||
* The views of the assistant ask for its status through this hook: not while
|
||||
* both addons that open /api/ai are off, where the server answers 404, and
|
||||
* as soon as one comes on, which the addon list can say after the view
|
||||
* mounted (the first load, an admin's switch). The assistant's own addon
|
||||
* switching asks again while AI parsing keeps /api/ai open.
|
||||
*/
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { aiConnectionsRepo } from '../../repo/aiConnectionsRepo';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAssistantStatus } from './useAssistantStatus';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
|
||||
|
||||
/** The addon list as it lands from the server, with these addons on. */
|
||||
const addonsOn = (...ids: string[]) =>
|
||||
seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useAssistantStatus', () => {
|
||||
it('FE-AI-STATUS-001: asks nothing before the addon list or with both addons off, and asks once one comes on', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
renderHook(() => useAssistantStatus());
|
||||
act(() => addonsOn('mcp'));
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(useAssistantStore.getState().status).toBeNull();
|
||||
|
||||
act(() => addonsOn('ai_assistant'));
|
||||
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The list read again with the assistant still on asks nothing more.
|
||||
act(() => addonsOn('ai_assistant', 'mcp'));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-STATUS-002: with AI parsing on it asks at once, and the last addon going off hides the assistant', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
addonsOn('llm_parsing');
|
||||
renderHook(() => useAssistantStatus());
|
||||
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => addonsOn());
|
||||
expect(useAssistantStore.getState().status).toBeNull();
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-STATUS-003: with AI parsing on, the assistant switched on or off asks again', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
addonsOn('llm_parsing');
|
||||
renderHook(() => useAssistantStatus());
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => addonsOn('llm_parsing', 'ai_assistant'));
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(2));
|
||||
|
||||
act(() => addonsOn('llm_parsing'));
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { aiRoutesOpen, useAssistantStore } from '../../store/assistantStore';
|
||||
|
||||
/**
|
||||
* Asks for the assistant's status when a view of it mounts, and again when
|
||||
* either addon that opens /api/ai is switched: the addon list can land after
|
||||
* the view, and an admin can switch either addon while it is open. The
|
||||
* assistant's own addon counts on its own, since the status says it runs only
|
||||
* while that one is on, even with AI parsing keeping /api/ai open. The store
|
||||
* asks the server only while one is on (aiRoutesOpen).
|
||||
*/
|
||||
export function useAssistantStatus(): void {
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const routesOpen = useAddonStore(aiRoutesOpen);
|
||||
const assistantOn = useAddonStore((s) => s.isEnabled('ai_assistant'));
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [routesOpen, assistantOn, loadStatus]);
|
||||
}
|
||||
@@ -25,13 +25,25 @@ interface CollabLink {
|
||||
pinned: boolean | number
|
||||
}
|
||||
|
||||
// The glyph holds the place until a favicon has really arrived: a host that never
|
||||
// answers, or answers with a blank pixel, left the chip with an empty square.
|
||||
function LinkIcon({ url }: { url: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [state, setState] = useState<'loading' | 'loaded' | 'failed'>('loading')
|
||||
let favicon = ''
|
||||
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
|
||||
const loaded = state === 'loaded'
|
||||
return (
|
||||
<span className="collab-link-chip__icon" aria-hidden="true">
|
||||
{!favicon || failed ? <Link2 size={13} /> : <img src={favicon} alt="" onError={() => setFailed(true)} />}
|
||||
{!loaded && <Link2 size={13} />}
|
||||
{favicon && state !== 'failed' && (
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
hidden={!loaded}
|
||||
onLoad={(e) => setState(e.currentTarget.naturalWidth > 2 ? 'loaded' : 'failed')}
|
||||
onError={() => setState('failed')}
|
||||
/>
|
||||
)}
|
||||
<span className="collab-link-chip__go"><ExternalLink size={13} /></span>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -38,6 +38,20 @@ function VoterChip({ name, src, offset }: { name: string; src: string | null; of
|
||||
);
|
||||
}
|
||||
|
||||
/** The voters past the third, as a count on the end of the stack; their names on hover. */
|
||||
function MoreVoters({ names }: { names: string[] }) {
|
||||
return (
|
||||
<Tooltip label={names.map((name) => name || '?').join(', ')}>
|
||||
<span
|
||||
className="grid h-[18px] min-w-[18px] flex-none place-items-center rounded-full border-[1.5px] border-surface-card bg-surface-tertiary px-[3px] font-bold tabular-nums text-content-muted"
|
||||
style={{ ...fs(7), marginInlineStart: -5 }}
|
||||
>
|
||||
+{names.length}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A fact about the poll on its head band, set like the type chip on a booking card. */
|
||||
export function PollChip({
|
||||
icon,
|
||||
@@ -138,6 +152,7 @@ export function PollOptionRow({
|
||||
{voters.slice(0, 3).map((voter, index) => (
|
||||
<VoterChip key={voter.key} name={voter.name} src={voter.src} offset={index > 0} />
|
||||
))}
|
||||
{voters.length > 3 && <MoreVoters names={voters.slice(3).map((voter) => voter.name)} />}
|
||||
</span>
|
||||
)}
|
||||
{pending && <span className="sr-only">{pending.label}</span>}
|
||||
|
||||
@@ -191,6 +191,18 @@ describe('CollabPolls', () => {
|
||||
expect(await screen.findByText(/closed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-025: past three voters the stack ends in a count of the rest', async () => {
|
||||
const voters = ['anna', 'ben', 'cleo', 'dana', 'emil'].map((username, i) => ({ user_id: 10 + i, username, avatar_url: null }));
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ is_closed: true, options: [{ id: 1, text: 'Paris', label: 'Paris', voters }, { id: 2, text: 'Rome', label: 'Rome', voters: [] }] })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
expect(await screen.findByText('+2')).toBeInTheDocument();
|
||||
expect(screen.queryByText('D')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { buildTrip } from '../../../tests/helpers/factories'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -171,6 +174,20 @@ describe('WhatsNextWidget', () => {
|
||||
expect(screen.getByText('TBD')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-018: a hotel without a time of its own shows the check-in of the stay it begins', async () => {
|
||||
server.use(http.get('/api/trips/1/accommodations', () => HttpResponse.json({
|
||||
accommodations: [{ id: 7, trip_id: 1, place_id: 40, start_day_id: 1, end_day_id: 2, check_in: '15:00', check_out: '11:00' }],
|
||||
})))
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: { '1': [makeAssignment(40, { name: 'Hotel Kea', place_time: null })] },
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(await screen.findByText('15:00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('TBD')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -8,6 +8,8 @@ import { fs } from '../shared/DialogShell'
|
||||
import CollabPanelHead from './CollabPanelHead'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { localToday } from '../Planner/today'
|
||||
import { useTripStays } from '../Planner/useTripStays'
|
||||
import { stayStartingOn } from '../../utils/dayMerge'
|
||||
|
||||
function formatTime(timeStr, is12h) {
|
||||
if (!timeStr) return ''
|
||||
@@ -48,6 +50,10 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
const { days, assignments } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
// A hotel on the day its stay begins is timed by the check-in when the stop has no time of its own.
|
||||
const tripId = useTripStore(s => s.trip?.id ?? 0)
|
||||
const { tripAccommodations, loadAccommodations } = useTripStays(tripId)
|
||||
useEffect(() => { loadAccommodations() }, [loadAccommodations])
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
const now = new Date()
|
||||
@@ -60,14 +66,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
for (const a of dayAssignments) {
|
||||
if (!a.place) continue
|
||||
const time = a.place.place_time || stayStartingOn(tripAccommodations, a.place.id, day.id)?.check_in || null
|
||||
// Include: today (not started yet, or still running) + all future days
|
||||
const isFutureDay = day.date > nowDate
|
||||
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime || (!!a.place.end_time && a.place.end_time >= nowTime))
|
||||
const isTodayFuture = day.date === nowDate && (!time || time >= nowTime || (!!a.place.end_time && a.place.end_time >= nowTime))
|
||||
if (isFutureDay || isTodayFuture) {
|
||||
items.push({
|
||||
id: a.id,
|
||||
name: a.place.name,
|
||||
time: a.place.place_time,
|
||||
time,
|
||||
endTime: a.place.end_time,
|
||||
date: day.date,
|
||||
dayTitle: day.title,
|
||||
@@ -88,7 +95,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
})
|
||||
|
||||
return items.slice(0, 8)
|
||||
}, [days, assignments, tripMembers])
|
||||
}, [days, assignments, tripMembers, tripAccommodations])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* The collection map frames every place of the list in the box it is given (FE-COMP-COLMAP-*).
|
||||
*
|
||||
* Real Leaflet on purpose, as in MapView.leaflet.test.tsx. The places went missing between
|
||||
* two sizes: the renderer opened on a camera framed for the browser window, while this map
|
||||
* is a box a fraction of that, and a mocked map has neither. The window here is a desktop
|
||||
* of 1600 x 1000 and the map the 567 x 724 split it gets beside the list, where the demo's
|
||||
* cafes opened on northern France with one pin in sight.
|
||||
*/
|
||||
import type { CollectionPlace } from '@trek/shared';
|
||||
import L from 'leaflet';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import CollectionMap from './CollectionMap';
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
useGeolocation: () => ({
|
||||
position: null,
|
||||
mode: 'off',
|
||||
error: null,
|
||||
errorCode: null,
|
||||
cycleMode: vi.fn(),
|
||||
setMode: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../Ferratas/FerrataLayerLeaflet', () => ({ FerrataMapLayerLeaflet: () => null }));
|
||||
|
||||
// A raster template keeps the basemap a plain TileLayer: the vector default needs WebGL.
|
||||
vi.mock('../../hooks/useTileUrl', () => ({ useTileUrl: () => 'https://tiles.example.test/{z}/{x}/{y}.png' }));
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
// Every map this file builds, newest last.
|
||||
const maps: L.Map[] = [];
|
||||
L.Map.addInitHook(function (this: L.Map) {
|
||||
maps.push(this);
|
||||
});
|
||||
|
||||
/** Half a selected pin (44 px): a point this far inside an edge keeps its whole pin on the map. */
|
||||
const PIN = 22;
|
||||
/** The desktop panel's control bar ends 52 px below the map's top edge. */
|
||||
const BAR = 52;
|
||||
/** The satellite and locate buttons fill the bottom 54 px of the map's corners. */
|
||||
const CONTROLS = 54;
|
||||
|
||||
// Leaflet sizes the map from its container, which jsdom lays out at 0 x 0.
|
||||
const box = { width: 567, height: 724 };
|
||||
const sized = ['clientWidth', 'clientHeight'] as const;
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const startWindow = { width: window.innerWidth, height: window.innerHeight };
|
||||
|
||||
function setWindow(width: number, height: number) {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width });
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: height });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
for (const key of sized) {
|
||||
originals.set(key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key));
|
||||
Object.defineProperty(HTMLElement.prototype, key, {
|
||||
configurable: true,
|
||||
get: () => (key === 'clientWidth' ? box.width : box.height),
|
||||
});
|
||||
}
|
||||
});
|
||||
afterAll(() => {
|
||||
for (const key of sized) {
|
||||
const original = originals.get(key);
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, key, original);
|
||||
else delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// The desktop split beside the list, unless a test says otherwise.
|
||||
setWindow(1600, 1000);
|
||||
Object.assign(box, { width: 567, height: 724 });
|
||||
});
|
||||
afterEach(() => {
|
||||
setWindow(startWindow.width, startWindow.height);
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
function place(id: number, name: string, lat: number, lng: number): CollectionPlace {
|
||||
return { id, collection_id: 1, name, lat, lng, status: 'idea' };
|
||||
}
|
||||
|
||||
// The demo's cafes: Vienna twice, Trieste, Rome, Paris, Lisbon, Porto and Copenhagen.
|
||||
const CAFES = [
|
||||
place(1, 'Cafe Central', 48.2105, 16.366),
|
||||
place(2, 'Cafe Sperl', 48.1999, 16.3634),
|
||||
place(3, 'Caffe San Marco', 45.6513, 13.777),
|
||||
place(4, "Sant'Eustachio il Caffe", 41.8984, 12.4753),
|
||||
place(5, 'Cafe de Flore', 48.8542, 2.3331),
|
||||
place(6, 'A Brasileira', 38.7102, -9.1417),
|
||||
place(7, 'Cafe Majestic', 41.1472, -8.6064),
|
||||
place(8, 'The Coffee Collective', 55.696, 12.5509),
|
||||
];
|
||||
|
||||
// The demo's viewpoints, from the Norwegian fjords to the Cinque Terre.
|
||||
const VIEWPOINTS = [
|
||||
place(11, 'Seceda', 46.6046, 11.722),
|
||||
place(12, 'Preikestolen', 58.9863, 6.1904),
|
||||
place(13, 'Trolltunga', 60.1241, 6.74),
|
||||
place(14, 'Rigi Kulm', 47.056, 8.485),
|
||||
place(15, 'Zugspitze', 47.4211, 10.9852),
|
||||
place(16, 'Eibsee loop', 47.4575, 10.9918),
|
||||
place(17, 'Hallstatt overlook', 47.5622, 13.6493),
|
||||
place(18, 'Monterosso al Mare', 44.1456, 9.654),
|
||||
];
|
||||
|
||||
// Temples and shrines across Japan: eight as well, and far outside the cafes' frame.
|
||||
const TEMPLES = [
|
||||
place(31, 'Fushimi Inari', 34.9671, 135.7727),
|
||||
place(32, 'Kiyomizu-dera', 34.9949, 135.785),
|
||||
place(33, 'Senso-ji', 35.7148, 139.7967),
|
||||
place(34, 'Chureito Pagoda', 35.5014, 138.8016),
|
||||
place(35, 'Itsukushima', 34.2959, 132.3199),
|
||||
place(36, 'Himeji Castle', 34.8394, 134.6939),
|
||||
place(37, 'Shirakawa-go', 36.2578, 136.9066),
|
||||
place(38, 'Todai-ji', 34.689, 135.8398),
|
||||
];
|
||||
|
||||
// "All saved" across the globe, from Patagonia to Taiwan.
|
||||
const WORLD = [
|
||||
place(21, 'Torres del Paine', -50.942, -73.4068),
|
||||
place(22, 'Pico do Arieiro', 32.7356, -16.9281),
|
||||
place(23, 'Landmannalaugar', 63.9926, -19.0607),
|
||||
place(24, 'Taroko Gorge', 24.1594, 121.6211),
|
||||
place(25, 'Lycian Way', 36.5497, 29.1153),
|
||||
...VIEWPOINTS.slice(0, 3),
|
||||
];
|
||||
|
||||
function mapOf(places: CollectionPlace[], underBar: boolean) {
|
||||
return (
|
||||
<CollectionMap places={places} selectedPlaceId={null} onOpenPlace={vi.fn()} dark={false} underBar={underBar} />
|
||||
);
|
||||
}
|
||||
|
||||
async function renderMap(places: CollectionPlace[], underBar = true) {
|
||||
const view = render(mapOf(places, underBar));
|
||||
await waitFor(() => expect(view.container.querySelector('.leaflet-container')).not.toBeNull());
|
||||
return { view, map: maps[maps.length - 1] };
|
||||
}
|
||||
|
||||
/** Where each place sits on the map, in pixels from its top left corner. */
|
||||
function pointsOf(map: L.Map, places: CollectionPlace[]): L.Point[] {
|
||||
return places.map((p) => map.latLngToContainerPoint([p.lat ?? 0, p.lng ?? 0]));
|
||||
}
|
||||
|
||||
/** Every pin whole on the map, below the bar when there is one, and above the bottom controls. */
|
||||
function expectEveryPinClear(map: L.Map, places: CollectionPlace[], top: number) {
|
||||
for (const { x, y } of pointsOf(map, places)) {
|
||||
expect(x).toBeGreaterThanOrEqual(PIN);
|
||||
expect(x).toBeLessThanOrEqual(box.width - PIN);
|
||||
expect(y).toBeGreaterThanOrEqual(top + PIN);
|
||||
expect(y).toBeLessThanOrEqual(box.height - CONTROLS - PIN);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an element around the map carries the class. */
|
||||
function wrappedIn(map: L.Map, className: string): boolean {
|
||||
for (let el = map.getContainer().parentElement; el; el = el.parentElement) {
|
||||
if (el.classList.contains(className)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The places fill a good part of the free box along one axis: framed, not shown from orbit. */
|
||||
function expectFramedClosely(map: L.Map, places: CollectionPlace[], top: number) {
|
||||
const points = pointsOf(map, places);
|
||||
const spanX = Math.max(...points.map((p) => p.x)) - Math.min(...points.map((p) => p.x));
|
||||
const spanY = Math.max(...points.map((p) => p.y)) - Math.min(...points.map((p) => p.y));
|
||||
const freeX = box.width - 2 * PIN;
|
||||
const freeY = box.height - top - CONTROLS - 2 * PIN;
|
||||
expect(Math.max(spanX / freeX, spanY / freeY)).toBeGreaterThan(0.4);
|
||||
}
|
||||
|
||||
describe('CollectionMap framing', () => {
|
||||
it('FE-COMP-COLMAP-001: the desktop split opens with every cafe on screen, clear of the edges and the bar', async () => {
|
||||
const { map } = await renderMap(CAFES);
|
||||
|
||||
expectEveryPinClear(map, CAFES, BAR);
|
||||
expectFramedClosely(map, CAFES, BAR);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-002: another list of the same length frames again', async () => {
|
||||
const { view, map } = await renderMap(CAFES);
|
||||
|
||||
// Both lists hold eight places, so a fit keyed on the count never noticed the switch.
|
||||
// The temples lie far east of the cafes' frame, so their pins come clear only by a new fit.
|
||||
expect(pointsOf(map, TEMPLES).every((p) => p.x > box.width)).toBe(true);
|
||||
view.rerender(mapOf(TEMPLES, true));
|
||||
|
||||
await waitFor(() => expectEveryPinClear(map, TEMPLES, BAR));
|
||||
expectFramedClosely(map, TEMPLES, BAR);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-003: a map the user moved stays put through a reload, a new order or an edited place', async () => {
|
||||
const { view, map } = await renderMap(CAFES);
|
||||
act(() => {
|
||||
map.setView([50, 10], 7, { animate: false });
|
||||
});
|
||||
|
||||
const reloaded = [...CAFES].reverse().map((p) => ({ ...p, status: 'visited' as const }));
|
||||
view.rerender(mapOf(reloaded, true));
|
||||
|
||||
expect(map.getZoom()).toBe(7);
|
||||
expect(map.getCenter().lat).toBeCloseTo(50, 5);
|
||||
expect(map.getCenter().lng).toBeCloseTo(10, 5);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-004: a filter that hides every place leaves the map where it was', async () => {
|
||||
const { view, map } = await renderMap(CAFES);
|
||||
const zoom = map.getZoom();
|
||||
const center = map.getCenter();
|
||||
|
||||
view.rerender(mapOf([], true));
|
||||
|
||||
expect(map.getZoom()).toBe(zoom);
|
||||
expect(map.getCenter().lat).toBeCloseTo(center.lat, 5);
|
||||
expect(map.getCenter().lng).toBeCloseTo(center.lng, 5);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-005: a list of one opens on its town, not on its street', async () => {
|
||||
const { map } = await renderMap([CAFES[0]]);
|
||||
|
||||
expectEveryPinClear(map, [CAFES[0]], BAR);
|
||||
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
|
||||
expect(map.getZoom()).toBeLessThanOrEqual(13);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-006: "All saved" across the globe keeps Patagonia and Taiwan on screen', async () => {
|
||||
const { map } = await renderMap(WORLD);
|
||||
|
||||
expectEveryPinClear(map, WORLD, BAR);
|
||||
});
|
||||
|
||||
it("FE-COMP-COLMAP-007: the phone's card frames every cafe clear of its edges and its bottom controls", async () => {
|
||||
setWindow(390, 844);
|
||||
Object.assign(box, { width: 356, height: 414 });
|
||||
|
||||
const { map } = await renderMap(CAFES, false);
|
||||
|
||||
expectEveryPinClear(map, CAFES, 0);
|
||||
expectFramedClosely(map, CAFES, 0);
|
||||
// The locate and satellite buttons stand on --bottom-nav-h, which lifts them over the tab
|
||||
// bar under a full-screen map. The card ends above the tab bar, so its wrapper zeroes it
|
||||
// and the buttons sit in the bottom 54 px the frame keeps free.
|
||||
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { MapViewAuto } from '../Map/MapViewAuto'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import { mappablePlaces } from '../../pages/collections/collectionsModel'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
|
||||
import type { ViewportPadding } from '../../utils/mapViewport'
|
||||
|
||||
interface CollectionMapProps {
|
||||
places: CollectionPlace[]
|
||||
@@ -12,20 +13,75 @@ interface CollectionMapProps {
|
||||
/** Clicking the map background clears the selection. */
|
||||
onDeselect?: () => void
|
||||
dark: boolean
|
||||
/** A control bar floats over the top of the map (the desktop panel's), so the frame starts below it. */
|
||||
underBar?: boolean
|
||||
}
|
||||
|
||||
/** How far a pin reaches from its point: half a selected pin (44 px) and the rating badge on its corner. */
|
||||
const PIN_REACH = 26
|
||||
/** What every edge keeps clear: a pin's reach and a little air, so no pin touches the frame. */
|
||||
const EDGE = PIN_REACH + 14
|
||||
/** The desktop panel's control bar: 12 px off the top edge and at most 40 px tall. */
|
||||
const TOP_BAR = 52
|
||||
/** The round controls in the bottom corners (satellite, locate): 12 px off the edge and 42 px tall. */
|
||||
const BOTTOM_CONTROLS = 54
|
||||
/**
|
||||
* Half the side of the square framed around a lone place, in degrees: about 11 km
|
||||
* from north to south, so a list of one opens on its town rather than on the street
|
||||
* the closest fit would zoom to.
|
||||
*/
|
||||
const LONE_PLACE_SPAN = 0.05
|
||||
|
||||
/** What the frame keeps clear of pins, in map pixels per edge. */
|
||||
function framePadding(underBar: boolean): ViewportPadding {
|
||||
const top = (underBar ? TOP_BAR : 0) + EDGE
|
||||
return { top, right: EDGE, bottom: BOTTOM_CONTROLS + PIN_REACH, left: EDGE } // rtl-lint-disable: pixels of the map, not sides of the text
|
||||
}
|
||||
|
||||
/**
|
||||
* Map view — reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
|
||||
* The coordinates the map frames. A lone place, or several on one spot, has no extent
|
||||
* to fit, so a small square around it stands in.
|
||||
*/
|
||||
function framePoints(places: CollectionPlace[]): [number, number][] {
|
||||
const points = places.flatMap(p => (
|
||||
typeof p.lat === 'number' && typeof p.lng === 'number' ? [[p.lat, p.lng] as [number, number]] : []
|
||||
))
|
||||
if (points.length === 0) return points
|
||||
const [lat, lng] = points[0]
|
||||
if (points.some(([a, b]) => a !== lat || b !== lng)) return points
|
||||
return [
|
||||
[Math.max(-90, lat - LONE_PLACE_SPAN), lng - LONE_PLACE_SPAN],
|
||||
[Math.min(90, lat + LONE_PLACE_SPAN), lng + LONE_PLACE_SPAN],
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Map view: reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
|
||||
* clustering). One of the three list views; clicking a marker selects the place.
|
||||
* The parent `.col-mapwrap` supplies the rounded, bordered box + height, so this
|
||||
* just fills it.
|
||||
*
|
||||
* The map frames every place itself, through the renderers' focus props. Their
|
||||
* opening camera is framed for the browser window, and this map is a box a fraction
|
||||
* of that size (the desktop split, the phone's card), so it opened zoomed in on the
|
||||
* middle of the list with most places outside; and the count it used to key the fit
|
||||
* on never changed between two lists of eight. The frame is fitted in the map's own
|
||||
* box instead, clear of the edges and of the controls floating over it, and lands
|
||||
* while the map is built rather than flying in.
|
||||
*/
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
|
||||
const pts = mappablePlaces(places)
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
|
||||
const pts = useMemo(() => mappablePlaces(places), [places])
|
||||
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
|
||||
const focusPoints = useMemo(() => framePoints(pts), [pts])
|
||||
// The places as a set: another list, a filter or a new place frames again, while a
|
||||
// reload, another sort order or an edited note leaves a map the user moved alone.
|
||||
const focusKey = useMemo(() => pts.map(p => p.id).sort((a, b) => a - b).join(','), [pts])
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
// The map's own controls stand on --bottom-nav-h to clear the phone's tab bar on a
|
||||
// full-screen map. This one ends above the tab bar, so they belong on its bottom edge,
|
||||
// where the frame's bottom padding keeps the pins out from under them.
|
||||
<div className="[--bottom-nav-h:0px]" style={{ width: '100%', height: '100%' }}>
|
||||
<MapViewAuto
|
||||
places={pts}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
@@ -35,10 +91,10 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on
|
||||
// question a small picture cannot answer.
|
||||
onMarkerClick={onOpenPlace}
|
||||
onMapClick={onDeselect ? () => onDeselect() : undefined}
|
||||
// No center/zoom: the map frames itself on the collection's places at mount, and
|
||||
// falls back to the world view for a collection with none.
|
||||
tileUrl={tileUrl}
|
||||
fitKey={pts.length}
|
||||
focusPoints={focusPoints}
|
||||
focusKey={focusKey}
|
||||
fitPadding={framePadding(underBar)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The collections page's map panel tells the map whether its bar floats over it (FE-COMP-COLMAPPANEL-*).
|
||||
*
|
||||
* The frame itself is CollectionMap.test.tsx's. The page tests stand the panel in with a double,
|
||||
* so this is where the bar's height reaches the frame: without it the top pins land under the bar.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import CollectionMapPanel from './CollectionMapPanel';
|
||||
|
||||
const handedToMap = vi.hoisted(() => ({ underBar: undefined as boolean | undefined }));
|
||||
vi.mock('./CollectionMap', () => ({
|
||||
default: (props: { underBar?: boolean }) => {
|
||||
handedToMap.underBar = props.underBar;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
function panel(overlay: boolean) {
|
||||
return (
|
||||
<CollectionMapPanel
|
||||
places={[]}
|
||||
selectedPlaceId={null}
|
||||
onSelect={vi.fn()}
|
||||
onDeselect={vi.fn()}
|
||||
dark={false}
|
||||
overlay={overlay}
|
||||
view="list"
|
||||
onToggleView={vi.fn()}
|
||||
search=""
|
||||
onSearch={vi.fn()}
|
||||
t={(key) => key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CollectionMapPanel', () => {
|
||||
it('FE-COMP-COLMAPPANEL-001: the desktop split floats its bar over the map, which frames its places below it', () => {
|
||||
const { container } = render(panel(true));
|
||||
|
||||
expect(container.querySelector('.col-map-topbar')).not.toBeNull();
|
||||
expect(handedToMap.underBar).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAPPANEL-002: a map without the bar frames its places from its own top edge', () => {
|
||||
const { container } = render(panel(false));
|
||||
|
||||
expect(container.querySelector('.col-map-topbar')).toBeNull();
|
||||
expect(handedToMap.underBar).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export default function CollectionMapPanel({
|
||||
onOpenPlace={onSelect}
|
||||
onDeselect={onDeselect}
|
||||
dark={dark}
|
||||
underBar={overlay}
|
||||
/>
|
||||
{overlay && (
|
||||
<div className="col-map-topbar">
|
||||
|
||||
@@ -7,7 +7,7 @@ import CustomSelect from '../../shared/CustomSelect'
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import Tooltip from '../../shared/Tooltip'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
||||
import DocSyncFlow, { type SyncDirection } from './DocSyncFlow'
|
||||
import { Badge, CONFLICT_POLICIES, conflictPolicyKey, LastRun, StateBadge } from './DocSyncBits'
|
||||
import { bindingNotice, needsReauth, type DocSyncLink, type useDocSync } from './useDocSync'
|
||||
|
||||
@@ -6,7 +6,7 @@ import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { useConnectForm } from './useConnectForm'
|
||||
import { Badge } from './DocSyncBits'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
||||
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
||||
import TrekIcon from '../../shared/TrekIcon'
|
||||
import { Badge } from './DocSyncBits'
|
||||
import { toggledDirection, type SyncDirection } from './docSyncModel'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { AlertCircle, ArrowRight, Check, FolderSync, Loader2, Plus } from 'lucide-react'
|
||||
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
||||
import { StateBadge } from './DocSyncBits'
|
||||
import { slugFor } from './docSyncModel'
|
||||
import { useConflicts } from './useConflicts'
|
||||
|
||||
@@ -534,6 +534,21 @@ describe('MapView', () => {
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-089: a frame handed over while the map is built lands at once, a later one flies', () => {
|
||||
// The map opens on a guess framed for the browser window. A map in a smaller box, the
|
||||
// collection's beside its list, hands its own frame over at once, and must open there
|
||||
// rather than show the guess and then fly to the frame.
|
||||
const cafes: [number, number][] = [[48.21, 16.37], [38.71, -9.14], [55.7, 12.55]]
|
||||
const { rerender } = render(<MapView places={[]} focusKey="list:17" focusPoints={cafes} />)
|
||||
expect(mapMock.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ animate: false, maxZoom: 16 })]])
|
||||
mapMock.fitBounds.mockClear()
|
||||
|
||||
// Another list on the same map is a change the user asked for, and it flies as before.
|
||||
const viewpoints: [number, number][] = [[60.12, 6.74], [44.15, 9.65], [47.56, 13.65]]
|
||||
rerender(<MapView places={[]} focusKey="list:20" focusPoints={viewpoints} />)
|
||||
expect(mapMock.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ animate: true, maxZoom: 16 })]])
|
||||
})
|
||||
|
||||
it('keeps selection fit and the pending route-arrival refit for semantic changes', () => {
|
||||
const selectedPlace = buildMapPlace({ id: 901, lat: 48, lng: 11 })
|
||||
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
|
||||
|
||||
@@ -427,35 +427,16 @@ function MapController({ center, zoom }: MapControllerProps) {
|
||||
// fit to that day's destinations immediately, then — once the day's route has
|
||||
// finished computing asynchronously — re-fit once more to include the full route
|
||||
// polyline, so a route that bulges past its stops stays in view (#1128).
|
||||
interface BoundsControllerProps {
|
||||
// The focus props are the contract's, documented there once rather than copied here.
|
||||
interface BoundsControllerProps extends Pick<MapViewProps, 'focusPoints' | 'focusKey' | 'fitPadding'> {
|
||||
places: Place[]
|
||||
routeCoords: [number, number][]
|
||||
fitKey: number
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
/** The map was built already framed on these places, so the opening fit has nothing to do. */
|
||||
framedOnMount?: boolean
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
|
||||
* for itself that it means "the selected day". Weighing the ways of driving one leg
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
/** Changes only when the caller intentionally wants a new initial frame. */
|
||||
focusKey?: number | string
|
||||
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
|
||||
follow?: boolean
|
||||
/**
|
||||
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
|
||||
*
|
||||
* `paddingOpts` knows the planner's panels and nothing else, and on a phone it is a flat
|
||||
* margin. A shell that lays its own bars over the map passes what they cover, so the
|
||||
* frame lands in the part still visible. Only the fit on `focusPoints` reads it.
|
||||
* Compared by value: the same numbers in a new object do not refit, while new numbers
|
||||
* refit the points already handed over, because the chrome they must clear has moved.
|
||||
*/
|
||||
fitPadding?: ViewportPadding
|
||||
}
|
||||
|
||||
/** A padding box as Leaflet's fit options spell it, corner pairs rather than edges. */
|
||||
@@ -473,6 +454,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
const fitRan = useRef(false)
|
||||
const didInitialFocus = useRef(false)
|
||||
const prevFocusKey = useRef(focusKey)
|
||||
const built = useRef(false)
|
||||
|
||||
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
|
||||
if (coords.length === 0) return
|
||||
@@ -493,7 +475,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
* (#1982). MapViewGL never had the nudge, so this also brings the two
|
||||
* renderers back into agreement.
|
||||
*/
|
||||
map.fitBounds(bounds, { ...padding, maxZoom: 16, animate: true })
|
||||
map.fitBounds(bounds, { maxZoom: 16, animate: true, ...padding })
|
||||
}
|
||||
} catch {}
|
||||
}, [map, paddingOpts])
|
||||
@@ -534,6 +516,10 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
// Frame whatever was handed over. Nothing happens when it empties, so closing the
|
||||
// picker leaves the map where the user left it rather than snapping back.
|
||||
useEffect(() => {
|
||||
// The first run is the map being built. A frame handed over by then is where the map
|
||||
// opens, so it lands at once instead of flying in from the window-sized opening guess.
|
||||
const opening = !built.current
|
||||
built.current = true
|
||||
if (!focusPoints?.length) return
|
||||
if (focusKey !== undefined) {
|
||||
const shouldFit = !didInitialFocus.current || focusKey !== prevFocusKey.current
|
||||
@@ -543,7 +529,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
}
|
||||
// A day fit that has not run yet must not overwrite this a moment later.
|
||||
awaitingRoute.current = false
|
||||
fitTo(focusPoints, fitPadding ? leafletPadding(fitPadding) : paddingOpts)
|
||||
fitTo(focusPoints, { ...(fitPadding ? leafletPadding(fitPadding) : paddingOpts), animate: !opening })
|
||||
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null
|
||||
|
||||
@@ -7,8 +7,8 @@ import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
// The planner-waypoint and location-search camera cases of MapViewGL, and the tour
|
||||
// map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
|
||||
// The planner-waypoint, location-search and antimeridian camera cases of MapViewGL, and
|
||||
// the tour map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
|
||||
|
||||
// Stable fake map so fitBounds call counts survive re-renders. The canvas
|
||||
// container is a single element so listeners registered by the component are
|
||||
@@ -356,6 +356,31 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('frames handed-over points across the antimeridian the short way, as the opening camera does', async () => {
|
||||
loadOnAttach();
|
||||
// Fiji, Tonga and Samoa, a collection's list: a box from plain min and max would run
|
||||
// from -175 to 178, round the far side of the world, with each pin on an edge.
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
followSelection={false}
|
||||
focusKey="1,2,3"
|
||||
focusPoints={[
|
||||
[-18.14, 178.44],
|
||||
[-21.14, -175.2],
|
||||
[-13.83, -171.76],
|
||||
]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
const framed = glBounds.instances.find((bounds) => bounds === glMap.fitBounds.mock.calls[0][0]);
|
||||
const lngs = framed?.extend.mock.calls.map(([lngLat]) => Math.round(lngLat[0] * 100) / 100);
|
||||
expect(lngs).toEqual([178.44, 184.8, 188.24]);
|
||||
});
|
||||
|
||||
it.each(['maplibre-gl', 'mapbox-gl'] as const)(
|
||||
'RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera',
|
||||
async (provider) => {
|
||||
|
||||
@@ -944,7 +944,7 @@ describe('MapViewGL', () => {
|
||||
/>,
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce()
|
||||
expect(glMap.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ duration: 0 })]])
|
||||
glMap.fitBounds.mockClear()
|
||||
const mapConstructions = (maplibregl.Map as any).mock.calls.length
|
||||
const retainedCenter = { lng: 135.8, lat: 35.2 }
|
||||
@@ -979,7 +979,7 @@ describe('MapViewGL', () => {
|
||||
/>,
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce()
|
||||
expect(glMap.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ duration: 400 })]])
|
||||
})
|
||||
|
||||
it('keeps selection fit and the pending route-arrival refit for semantic changes', async () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack,
|
||||
import { buildPoiPopupHtml } from './placePopup'
|
||||
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
|
||||
|
||||
@@ -2091,7 +2091,7 @@ export function MapViewGL({
|
||||
didInitialFocusRef.current = true
|
||||
}
|
||||
const bounds = new gl.LngLatBounds()
|
||||
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
||||
alongShortestArc(focusPoints).forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
||||
// A day fit still waiting on its route must not overwrite this a moment later.
|
||||
pendingRouteFitRef.current = null
|
||||
try {
|
||||
@@ -2099,7 +2099,7 @@ export function MapViewGL({
|
||||
padding: fitPadding ?? paddingOpts,
|
||||
maxZoom: 15,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
duration: mapReady ? 400 : 0, // handed over while the map is built: it opens there, no flight in
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -426,6 +426,23 @@ describe('calculateRouteWithLegs', () => {
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-035b: a route answered once is still there after a reload, without asking the router (#2794)', async () => {
|
||||
let hits = 0
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => { hits++; return HttpResponse.json(buildLegsResponse()) }))
|
||||
const wps = freshWaypoints()
|
||||
const first = await calculateRouteWithLegs(wps)
|
||||
const { offlineDb } = await import('../../db/offlineDb')
|
||||
await vi.waitFor(async () => expect(await offlineDb.routeCache.count()).toBeGreaterThan(0))
|
||||
|
||||
// A reload: the module, and with it the cache in memory, starts over.
|
||||
vi.resetModules()
|
||||
const reloaded = await import('./RouteCalculator')
|
||||
const again = await reloaded.calculateRouteWithLegs(wps)
|
||||
|
||||
expect(hits).toBe(1)
|
||||
expect(again).toEqual(first)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-036: switching the distance unit re-fetches instead of reusing stale text (#1300)', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(buildLegsResponse())))
|
||||
const wps = freshWaypoints()
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWay
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { countRoute } from './routeUsageCounter'
|
||||
import { cacheRoute, rememberRoute, routeInMemory, storedRoute } from './routeStore'
|
||||
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
|
||||
import { furthestFrom, sameRoad, type RouteUsageSurface } from '@trek/shared'
|
||||
|
||||
@@ -196,10 +197,6 @@ function retryAfterMs(response: Response): number | null {
|
||||
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
|
||||
}
|
||||
|
||||
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
|
||||
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
|
||||
const routeCache = new Map<string, RouteWithLegs>()
|
||||
const ROUTE_CACHE_MAX = 200
|
||||
|
||||
/**
|
||||
* A route profile is either one of the built-in OSRM profiles or a plugin profile
|
||||
@@ -540,7 +537,10 @@ export async function calculateRouteWithLegs(
|
||||
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
|
||||
const plainKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
|
||||
const cacheKey = `${plainKey}${avoidScope}`
|
||||
const cached = routeCache.get(cacheKey)
|
||||
// A plugin route stays in memory: its answer depends on the trip and the day,
|
||||
// and may carry live data such as charging stops. The rest is kept on the device
|
||||
// too, so a trip that shows its whole route opens drawn (#2794).
|
||||
const cached = pluginScope ? routeInMemory(cacheKey) : await storedRoute(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
if (avoiding.length) {
|
||||
@@ -552,7 +552,7 @@ export async function calculateRouteWithLegs(
|
||||
// Null is a Valhalla that could not answer at all. OSRM keeps the day drawn rather
|
||||
// than blank, and the answer says that nothing was avoided, with the reason, so the
|
||||
// rail flags the day instead of implying the setting held on a road nobody weighed.
|
||||
const plain = routeCache.get(plainKey) ?? await osrmWithLegs(waypoints, coords, profile, signal)
|
||||
const plain = (await storedRoute(plainKey)) ?? await osrmWithLegs(waypoints, coords, profile, signal)
|
||||
cacheRoute(plainKey, plain)
|
||||
return { ...plain, avoidance: { asked: avoiding, achieved: [], fellBack: true } }
|
||||
}
|
||||
@@ -591,11 +591,7 @@ export async function calculateRouteWithLegs(
|
||||
legs,
|
||||
...(route.viaPoints.length ? { vias: route.viaPoints } : {}),
|
||||
}
|
||||
routeCache.set(cacheKey, result)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
if (oldest !== undefined) routeCache.delete(oldest)
|
||||
}
|
||||
rememberRoute(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -638,15 +634,6 @@ async function osrmWithLegs(
|
||||
return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
}
|
||||
|
||||
/** Store a route and drop the oldest once the cache is over its cap. */
|
||||
function cacheRoute(key: string, route: RouteWithLegs): void {
|
||||
routeCache.set(key, route)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
if (oldest !== undefined) routeCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole chain from the second engine, weighted away from some road classes.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { routeCacheRepo } from '../../repo/routeCacheRepo';
|
||||
import type { RouteWithLegs } from '../../types';
|
||||
|
||||
/**
|
||||
* Route answers by RouteCalculator's cache key (profile, unit, waypoints,
|
||||
* avoided roads). Routes are stable, so this keeps the router from being asked
|
||||
* again on every day switch or reorder: in memory for this visit, and on the
|
||||
* device for the next one, so a trip that shows its whole route opens drawn
|
||||
* instead of routing every leg again, one a second (#2794).
|
||||
*/
|
||||
const inMemory = new Map<string, RouteWithLegs>();
|
||||
const MEMORY_MAX = 200;
|
||||
|
||||
/** Keeps a route for this visit only, dropping the oldest past the limit. */
|
||||
export function rememberRoute(key: string, route: RouteWithLegs): void {
|
||||
inMemory.set(key, route);
|
||||
if (inMemory.size > MEMORY_MAX) {
|
||||
const oldest = inMemory.keys().next().value;
|
||||
if (oldest !== undefined) inMemory.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** The route this visit already holds, without asking the device. */
|
||||
export function routeInMemory(key: string): RouteWithLegs | undefined {
|
||||
return inMemory.get(key);
|
||||
}
|
||||
|
||||
/** Keeps a route the router just answered, in memory and on the device. */
|
||||
export function cacheRoute(key: string, route: RouteWithLegs): void {
|
||||
rememberRoute(key, route);
|
||||
void routeCacheRepo.put(key, route);
|
||||
}
|
||||
|
||||
/** The route kept in memory, else the one kept on the device, which then stays in memory too. */
|
||||
export async function storedRoute(key: string): Promise<RouteWithLegs | null> {
|
||||
const held = inMemory.get(key);
|
||||
if (held) return held;
|
||||
const kept = await routeCacheRepo.get<RouteWithLegs>(key);
|
||||
if (kept) rememberRoute(key, kept);
|
||||
return kept;
|
||||
}
|
||||
@@ -5,19 +5,22 @@ import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
|
||||
import { useSuggestionDropdown } from './useSuggestionDropdown'
|
||||
import { PlaceSuggestion, SuggestionList, SuggestionRow } from './SuggestionDropdown'
|
||||
import type { MapMatch } from '../../repo/mapMatchRepo'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
/** A picked suggestion's spot on the map, for a form that saves where the address is (an imported stay). */
|
||||
onPick?: (match: MapMatch) => void
|
||||
}
|
||||
|
||||
// Free-text address input with location autocomplete, backed by the same maps
|
||||
// search as LocationSelect. Unlike LocationSelect the typed text is
|
||||
// authoritative: every keystroke reaches the parent so a hand-written address
|
||||
// is never lost, and picking a suggestion just replaces the text (#1496).
|
||||
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
|
||||
export default function AddressInput({ value, onChange, placeholder, className, onPick }: Props) {
|
||||
const { locale } = useTranslation()
|
||||
const placeLang = usePlaceLanguage()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
@@ -60,6 +63,9 @@ export default function AddressInput({ value, onChange, placeholder, className }
|
||||
// A response still on its way must not repopulate the list behind the pick.
|
||||
reqIdRef.current++
|
||||
onChange(r.address || r.name || '')
|
||||
if (onPick && Number.isFinite(r.lat) && Number.isFinite(r.lng)) {
|
||||
onPick({ lat: r.lat, lng: r.lng, label: [r.name, r.address].filter(Boolean).join(', ') })
|
||||
}
|
||||
setOpen(false)
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
|
||||
@@ -118,9 +118,10 @@ export function DayPlanPlaceSubline({
|
||||
const description = place.description || place.address || cat?.name;
|
||||
if (!(dayRowTour || place.place_time || hasPlannedTimes(tourTimes) || description)) return null;
|
||||
return (
|
||||
// One line like the row's name: when the hover controls come out, the description
|
||||
// gives way and the pills keep their place instead of wrapping onto a second line.
|
||||
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
||||
// The description stays on one line and gives way first. The pills wrap rather
|
||||
// than run out of the row: a tour carries a time, its duration and its distance,
|
||||
// and in the narrow day column the last of them used to be cut off unseen.
|
||||
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 overflow-hidden">
|
||||
{place.place_time && dayRowTour && (
|
||||
// TIME-02: a tour's end, typed or worked out from its start and its time.
|
||||
<TimePill quiet>
|
||||
|
||||
@@ -201,9 +201,9 @@ describe('DayPlanSidebar', () => {
|
||||
const view = render(<DayPlanSidebar {...props} />);
|
||||
const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement;
|
||||
expect(tourPreview).toHaveTextContent(description);
|
||||
// One line like the row's name, so hover controls make the description give way instead of wrapping.
|
||||
// The description keeps to one line; the pills beside it wrap rather than run out of the row.
|
||||
expect(tourPreview).toHaveStyle({ whiteSpace: 'nowrap', maxHeight: '1.3em' });
|
||||
expect(tourPreview.parentElement).toHaveClass('flex-nowrap', 'overflow-hidden');
|
||||
expect(tourPreview.parentElement).toHaveClass('flex-wrap', 'overflow-hidden');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h');
|
||||
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min');
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useReservationForm } from './useReservationForm'
|
||||
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, filesNeedConnection, uploadBookingFiles } from './bookingFormModel'
|
||||
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
|
||||
import { useBookingFileAttach } from './useBookingFileAttach'
|
||||
import StayPinStatus from './StayPinStatus'
|
||||
|
||||
const EMPTY_FORM = {
|
||||
title: '', type: 'other', status: 'pending',
|
||||
@@ -81,7 +82,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const {
|
||||
form, setForm, set, isSaving, setIsSaving, pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler,
|
||||
isEndBeforeStart, endTimeBeforeStartTime, dateBounds: tripDateRange, startDate, startTime, setStartDate, setStartTime, takeStopDay,
|
||||
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, saveData: buildSaveData,
|
||||
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, stayPin, saveData: buildSaveData,
|
||||
} = useReservationForm(EMPTY_FORM, days, places)
|
||||
const attach = useBookingFileAttach({ reservation, files, onFileUpload, setPendingFiles, toast, t })
|
||||
|
||||
@@ -102,17 +103,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
}
|
||||
|
||||
setTravelerIds(travelerIdsOf(reservation))
|
||||
stayPin.reset()
|
||||
if (reservation) {
|
||||
setForm({ ...reservationFieldsFrom(reservation, accommodations, places, true), assignment_id: reservation.assignment_id || '' })
|
||||
} else if (prefill) {
|
||||
// Review-before-save: populate from a parsed import item, stay in create mode.
|
||||
const hotelPlaceId = matchPlaceId(prefill._venue?.name || prefill.title)
|
||||
setForm({
|
||||
...reservationFieldsFromPrefill(prefill, days, true),
|
||||
assignment_id: defaultAssignmentId ?? '',
|
||||
accommodation_id: '',
|
||||
place_id: '',
|
||||
hotel_place_id: matchPlaceId(prefill._venue?.name || prefill.title),
|
||||
hotel_place_id: hotelPlaceId,
|
||||
})
|
||||
// A stay the trip does not hold yet: find it on the map now, so the review
|
||||
// says whether it will show there before it is saved.
|
||||
if (prefill.type === 'hotel' && !hotelPlaceId) stayPin.lookUp(prefill._venue?.name || prefill.title, prefill._venue?.address)
|
||||
// Seed the booking's Files with the document this item was parsed from.
|
||||
setPendingFiles(prefill._sourceFiles ?? [])
|
||||
} else {
|
||||
@@ -362,8 +368,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
<EditorField label={t('reservations.locationAddress')}>
|
||||
<AddressInput value={form.hotel_address} onChange={v => set('hotel_address', v)}
|
||||
onPick={stayPin.pick}
|
||||
placeholder={t('reservations.locationPlaceholder')} className={INPUT} />
|
||||
</EditorField>
|
||||
<StayPinStatus
|
||||
pin={stayPin.pin}
|
||||
hidden={!!form.hotel_place_id}
|
||||
onRetry={() => stayPin.lookUp(form.title, form.hotel_address)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||
import StayPinStatus from './StayPinStatus';
|
||||
import type { StayPin } from './useStayPin';
|
||||
|
||||
function show(pin: StayPin, onRetry = vi.fn()) {
|
||||
render(createElement(TranslationProvider, null, createElement(StayPinStatus, { pin, onRetry })));
|
||||
return onRetry;
|
||||
}
|
||||
|
||||
describe('StayPinStatus', () => {
|
||||
it('FE-STAY-PIN-STATUS-001: shows nothing before a lookup', () => {
|
||||
const { container } = render(
|
||||
createElement(
|
||||
TranslationProvider,
|
||||
null,
|
||||
createElement(StayPinStatus, { pin: { status: 'idle' }, onRetry: vi.fn() })
|
||||
)
|
||||
);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-STATUS-001b: a stay already linked to a place of the trip shows nothing', () => {
|
||||
const { container } = render(
|
||||
createElement(
|
||||
TranslationProvider,
|
||||
null,
|
||||
createElement(StayPinStatus, { pin: { status: 'missing' }, hidden: true, onRetry: vi.fn() })
|
||||
)
|
||||
);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-STATUS-002: says it is looking, then where it found the stay', () => {
|
||||
show({ status: 'searching' });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Looking for it on the map');
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-STATUS-003: a found stay names the match, so a wrong one is plain', () => {
|
||||
show({ status: 'found', match: { lat: 1, lng: 2, label: 'Hotel Gracery Shinjuku, Tokyo' } });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('On the map');
|
||||
expect(screen.getByText('Hotel Gracery Shinjuku, Tokyo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-STATUS-004: a missing stay says so and offers to search again', () => {
|
||||
const onRetry = show({ status: 'missing' });
|
||||
expect(screen.getByText('Not on the map yet')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /search again/i }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MapPin, MapPinOff, RotateCw } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { StayPin } from './useStayPin';
|
||||
|
||||
interface StayPinStatusProps {
|
||||
pin: StayPin;
|
||||
/** A stay already linked to a place of the trip shows on the map through that place. */
|
||||
hidden?: boolean;
|
||||
/** Asks the map again with what the form now holds. */
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an imported stay will show on the map, under its address in the
|
||||
* review: being looked up, found (and where, so a wrong match is plain), or not
|
||||
* found, with a way to search again before it is saved without a pin. Shared by
|
||||
* the desktop booking dialog and the phone's booking sheet.
|
||||
*/
|
||||
export default function StayPinStatus({ pin, hidden = false, onRetry }: StayPinStatusProps) {
|
||||
const { t } = useTranslation();
|
||||
if (hidden || pin.status === 'idle') return null;
|
||||
|
||||
if (pin.status === 'searching') {
|
||||
return (
|
||||
<div role="status" className="flex items-center gap-2 px-1 text-caption text-content-muted">
|
||||
<span className="h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-edge border-t-content-muted" />
|
||||
{t('reservations.import.pinSearching')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pin.status === 'found') {
|
||||
return (
|
||||
<div role="status" className="flex min-w-0 items-center gap-2 rounded-lg bg-success-soft px-3 py-2 text-caption">
|
||||
<MapPin size={14} strokeWidth={2.2} className="flex-none text-success" />
|
||||
<span className="flex-none font-semibold text-success">{t('reservations.import.pinFound')}</span>
|
||||
<span className="min-w-0 truncate text-content-secondary" title={pin.match.label}>
|
||||
{pin.match.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="status" className="flex items-start gap-3 rounded-xl bg-warning-soft px-3.5 py-3">
|
||||
<MapPinOff size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 text-body font-semibold text-content">{t('reservations.import.pinMissingTitle')}</p>
|
||||
<p className="m-0 mt-0.5 text-caption text-content-muted">{t('reservations.import.pinMissingBody')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-edge bg-surface-card px-2.5 py-1 text-caption font-medium text-content hover:bg-surface-hover"
|
||||
>
|
||||
<RotateCw size={12} strokeWidth={2.2} />
|
||||
{t('reservations.import.pinRetry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -287,6 +287,8 @@ export interface ReservationSaveOptions {
|
||||
isEdit: boolean;
|
||||
/** The desktop also saves the end of the check-in window. */
|
||||
withCheckInEnd: boolean;
|
||||
/** Where the review found an imported stay on the map, so the save creates its place right there. */
|
||||
venuePin?: { lat: number; lng: number } | null;
|
||||
}
|
||||
|
||||
export type ReservationSaveData = Record<string, unknown> & { title: string };
|
||||
@@ -344,7 +346,11 @@ export function reservationSaveData(
|
||||
// import): the save handler geocodes it and creates the place.
|
||||
venue:
|
||||
!form.hotel_place_id && (form.hotel_address || form.title)
|
||||
? { name: form.title, address: form.hotel_address || null }
|
||||
? {
|
||||
name: form.title,
|
||||
address: form.hotel_address || null,
|
||||
...(opts.venuePin ? { lat: opts.venuePin.lat, lng: opts.venuePin.lng } : {}),
|
||||
}
|
||||
: null,
|
||||
// The typed address, so the save handler can write it through to a linked
|
||||
// place; an edited address used to be silently dropped (#1496).
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
withStartDate,
|
||||
withStartTime,
|
||||
} from './reservationFormModel';
|
||||
import { useStayPin } from './useStayPin';
|
||||
|
||||
/**
|
||||
* The booking form behind the desktop ReservationModal and the phone's booking
|
||||
@@ -39,6 +40,8 @@ export function useReservationForm<F extends ReservationFields & CheckInEndField
|
||||
|
||||
const set = (field: keyof F, value: string | number) => setForm((prev) => ({ ...prev, [field]: value }));
|
||||
const toggleTraveler = (id: number) => setTravelerIds((prev) => toggledTraveler(prev, id));
|
||||
// Where an imported stay will land on the map, looked up while it is reviewed.
|
||||
const stayPin = useStayPin();
|
||||
|
||||
// Restrict non-hotel booking dates to the trip's span (#1662). Hotels already
|
||||
// constrain to trip days via their day dropdowns.
|
||||
@@ -71,6 +74,7 @@ export function useReservationForm<F extends ReservationFields & CheckInEndField
|
||||
pickHotelPlace: (value: string | number) => setForm((prev) => withHotelPlace(prev, value, places)),
|
||||
pickHotelStart: (value: string | number) => setForm((prev) => withHotelStart(prev, value, days)),
|
||||
pickHotelEnd: (value: string | number) => setForm((prev) => withHotelEnd(prev, value, days)),
|
||||
saveData: (opts: ReservationSaveOptions) => reservationSaveData(form, opts),
|
||||
stayPin,
|
||||
saveData: (opts: ReservationSaveOptions) => reservationSaveData(form, { ...opts, venuePin: stayPin.match }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { findOnMap } from '../../repo/mapMatchRepo';
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode';
|
||||
import { useStayPin } from './useStayPin';
|
||||
|
||||
vi.mock('../../hooks/usePlaceLanguage', () => ({ usePlaceLanguage: () => 'en' }));
|
||||
vi.mock('../../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
|
||||
vi.mock('../../repo/mapMatchRepo', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../repo/mapMatchRepo')>()),
|
||||
findOnMap: vi.fn(),
|
||||
}));
|
||||
|
||||
const SPOT = { lat: 35.69, lng: 139.7, label: 'Hotel Gracery Shinjuku' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe('useStayPin', () => {
|
||||
it('FE-STAY-PIN-001: looks the stay up by name and address and says where it found it', async () => {
|
||||
vi.mocked(findOnMap).mockResolvedValueOnce(SPOT);
|
||||
const { result } = renderHook(() => useStayPin());
|
||||
expect(result.current.pin).toEqual({ status: 'idle' });
|
||||
|
||||
act(() => result.current.lookUp('Hotel Gracery', '1-19-1 Kabukicho'));
|
||||
expect(result.current.pin).toEqual({ status: 'searching' });
|
||||
await waitFor(() => expect(result.current.pin).toEqual({ status: 'found', match: SPOT }));
|
||||
expect(result.current.match).toEqual(SPOT);
|
||||
expect(findOnMap).toHaveBeenCalledWith(
|
||||
['Hotel Gracery 1-19-1 Kabukicho', 'Hotel Gracery', '1-19-1 Kabukicho'],
|
||||
'en'
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-002: nothing found, a failed search and an empty name all read as not on the map', async () => {
|
||||
const { result } = renderHook(() => useStayPin());
|
||||
vi.mocked(findOnMap).mockResolvedValueOnce(null);
|
||||
act(() => result.current.lookUp('Ryokan', null));
|
||||
await waitFor(() => expect(result.current.pin).toEqual({ status: 'missing' }));
|
||||
expect(result.current.match).toBeNull();
|
||||
|
||||
vi.mocked(findOnMap).mockRejectedValueOnce(new Error('down'));
|
||||
act(() => result.current.lookUp('Ryokan', null));
|
||||
await waitFor(() => expect(result.current.pin).toEqual({ status: 'missing' }));
|
||||
|
||||
act(() => result.current.lookUp('', ''));
|
||||
expect(result.current.pin).toEqual({ status: 'missing' });
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-003: a later lookup or a hand-picked spot wins over an answer still on its way', async () => {
|
||||
let answer: (m: typeof SPOT | null) => void = () => undefined;
|
||||
vi.mocked(findOnMap).mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
|
||||
const { result } = renderHook(() => useStayPin());
|
||||
act(() => result.current.lookUp('Hotel', 'Somewhere'));
|
||||
const picked = { lat: 1, lng: 2, label: 'Picked' };
|
||||
act(() => result.current.pick(picked));
|
||||
await act(async () => answer(SPOT));
|
||||
expect(result.current.pin).toEqual({ status: 'found', match: picked });
|
||||
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.pin).toEqual({ status: 'idle' });
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-004: offline there is nothing to ask, so the review shows nothing', () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
|
||||
const { result } = renderHook(() => useStayPin());
|
||||
act(() => result.current.lookUp('Hotel', 'Somewhere'));
|
||||
expect(result.current.pin).toEqual({ status: 'idle' });
|
||||
expect(findOnMap).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage';
|
||||
import { findOnMap, type MapMatch, stayQueries } from '../../repo/mapMatchRepo';
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode';
|
||||
|
||||
/**
|
||||
* Where an imported stay will sit on the map, as the review shows it: not asked
|
||||
* yet (or offline, where there is nothing to ask), being looked up, found, or
|
||||
* not found at all.
|
||||
*/
|
||||
export type StayPin =
|
||||
{ status: 'idle' } | { status: 'searching' } | { status: 'found'; match: MapMatch } | { status: 'missing' };
|
||||
|
||||
/**
|
||||
* The map spot of a stay reviewed from an import, looked up before it is saved
|
||||
* so the review can say whether it will show on the map. A stay the search did
|
||||
* not find used to be saved without coordinates and simply never appeared.
|
||||
* A later lookup always wins over an earlier one still on its way.
|
||||
*/
|
||||
export function useStayPin() {
|
||||
const lang = usePlaceLanguage();
|
||||
const [pin, setPin] = useState<StayPin>({ status: 'idle' });
|
||||
const ticket = useRef(0);
|
||||
useEffect(
|
||||
() => () => {
|
||||
ticket.current++;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const lookUp = (name: string | null | undefined, address: string | null | undefined) => {
|
||||
const mine = ++ticket.current;
|
||||
const queries = stayQueries(name ?? '', address);
|
||||
if (isEffectivelyOffline()) {
|
||||
setPin({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
if (queries.length === 0) {
|
||||
setPin({ status: 'missing' });
|
||||
return;
|
||||
}
|
||||
setPin({ status: 'searching' });
|
||||
findOnMap(queries, lang).then(
|
||||
(match) => {
|
||||
if (mine === ticket.current) setPin(match ? { status: 'found', match } : { status: 'missing' });
|
||||
},
|
||||
() => {
|
||||
if (mine === ticket.current) setPin({ status: 'missing' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
pin,
|
||||
/** The spot to save the stay at, once one is known. */
|
||||
match: pin.status === 'found' ? pin.match : null,
|
||||
lookUp,
|
||||
/** A spot the reader picked by hand, which beats any lookup. */
|
||||
pick: (match: MapMatch) => {
|
||||
ticket.current++;
|
||||
setPin({ status: 'found', match });
|
||||
},
|
||||
reset: () => {
|
||||
ticket.current++;
|
||||
setPin({ status: 'idle' });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
@@ -33,6 +34,8 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.models.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAdmin, buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
|
||||
@@ -51,6 +52,8 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.models.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]);
|
||||
repo.disconnect.mockResolvedValue(undefined);
|
||||
repo.chatgptStart.mockResolvedValue({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-AI-CONNECT-MODEL-001 to FE-AI-CONNECT-MODEL-007: the pure rules behind connecting an AI provider.
|
||||
// FE-AI-CONNECT-MODEL-001 to FE-AI-CONNECT-MODEL-008: the pure rules behind connecting an AI provider.
|
||||
import type { AiConnectionView } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
connectErrorKey,
|
||||
httpHostOf,
|
||||
isPublicHttpsUrl,
|
||||
openrouterReturnCode,
|
||||
refusalCode,
|
||||
rememberOpenrouterFlow,
|
||||
repairModeOf,
|
||||
@@ -93,4 +94,14 @@ describe('aiConnectModel', () => {
|
||||
expect(src).not.toContain('<');
|
||||
expect(decodeURIComponent(src.split(',')[1])).toBe('<svg><path d="M0 0"/></svg>');
|
||||
});
|
||||
|
||||
it('FE-AI-CONNECT-MODEL-008: reads the OpenRouter return with the code after an & or after a second ?', () => {
|
||||
const read = (query: string) => openrouterReturnCode(new URLSearchParams(query));
|
||||
expect(read('tab=integrations&ai=openrouter&code=abc')).toBe('abc');
|
||||
expect(read('tab=integrations&ai=openrouter?code=abc')).toBe('abc');
|
||||
expect(read('tab=addons&ai=openrouter?code=abc&state=x')).toBe('abc');
|
||||
expect(read('ai=openrouter')).toBeNull();
|
||||
expect(read('tab=integrations')).toBeUndefined();
|
||||
expect(read('ai=chatgpt&code=abc')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,26 @@ export function repairModeOf(view: AiConnectionView): AiConnectMode | null {
|
||||
return view.kind === 'custom' ? 'custom' : 'key';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a pasted ChatGPT callback ended this sign-in rather than an earlier
|
||||
* one: its `state` is the one the sign-in address carried. The server takes a
|
||||
* sign-in only once, so an address from an earlier attempt still on the
|
||||
* clipboard would use up the one in progress. Without a state on either side
|
||||
* the server decides.
|
||||
*/
|
||||
export function callbackMatchesFlow(callback: string, authorizeUrl: string): boolean {
|
||||
const stateOf = (address: string) => {
|
||||
try {
|
||||
return new URL(address.trim()).searchParams.get('state');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const expected = stateOf(authorizeUrl);
|
||||
const pasted = stateOf(callback);
|
||||
return !expected || !pasted || pasted === expected;
|
||||
}
|
||||
|
||||
// The OpenRouter sign-in leaves the page and comes back with a code only; the
|
||||
// flow it belongs to waits here for that one return. Tab-scoped on purpose.
|
||||
const OPENROUTER_FLOW_KEY = 'trek.ai.openrouterFlow';
|
||||
@@ -79,6 +99,21 @@ export function takeOpenrouterFlow(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The code an OpenRouter sign-in came back with, or `undefined` when the page
|
||||
* was not opened by one. The return address already carries a query
|
||||
* (`?tab=…&ai=openrouter`), and OpenRouter may add its code with a second `?`
|
||||
* rather than an `&`, which leaves it inside the `ai` value
|
||||
* (`ai=openrouter?code=…`); both shapes count.
|
||||
*/
|
||||
export function openrouterReturnCode(params: URLSearchParams): string | null | undefined {
|
||||
const ai = params.get('ai');
|
||||
if (ai === null) return undefined;
|
||||
const [marker, rest] = ai.split('?', 2);
|
||||
if (marker !== 'openrouter') return undefined;
|
||||
return params.get('code') ?? new URLSearchParams(rest ?? '').get('code');
|
||||
}
|
||||
|
||||
const PRIVATE_V4 = [/^10\./, /^127\./, /^169\.254\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./, /^0\./];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-HOOK-AI-CONNECT-001 to FE-HOOK-AI-CONNECT-013: pasting a key, an own server, the ChatGPT sign-in and the OpenRouter return.
|
||||
// FE-HOOK-AI-CONNECT-001 to FE-HOOK-AI-CONNECT-015: pasting a key, an own server, the ChatGPT sign-in and the OpenRouter return.
|
||||
import { act, fireEvent, renderHook, waitFor } from '@testing-library/react';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter, useLocation } from 'react-router';
|
||||
@@ -249,6 +249,52 @@ describe('useChatgptConnect', () => {
|
||||
expect(readText).toHaveBeenCalledTimes(1);
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-CONNECT-014: the same address handed in twice at once is sent once, so the sign-in is not used up', async () => {
|
||||
let answer: (value: unknown) => void = () => undefined;
|
||||
repo.chatgptFinish.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
|
||||
const onConnected = vi.fn();
|
||||
const { result } = renderHook(() => useChatgptConnect({ active: true, onConnected }), { wrapper: wrapperAt() });
|
||||
await waitFor(() => expect(result.current.flow).not.toBeNull());
|
||||
act(() => {
|
||||
result.current.paste(CALLBACK);
|
||||
result.current.paste(CALLBACK);
|
||||
});
|
||||
await act(async () => answer({}));
|
||||
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
|
||||
expect(repo.chatgptFinish).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-CONNECT-015: an address from an earlier sign-in is not sent, and the one in progress stays usable', async () => {
|
||||
repo.chatgptStart.mockResolvedValue({ ...FLOW, authorizeUrl: 'https://auth.openai.com/oauth/authorize?state=s2' });
|
||||
const readText = vi.fn(async () => CALLBACK);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { readText } });
|
||||
Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true });
|
||||
const onConnected = vi.fn();
|
||||
const { result } = renderHook(() => useChatgptConnect({ active: true, onConnected }), { wrapper: wrapperAt() });
|
||||
await waitFor(() => expect(result.current.flow).not.toBeNull());
|
||||
|
||||
// Back from the sign-in window with the old address still on the clipboard: nothing is sent.
|
||||
act(() => result.current.markOpened());
|
||||
fireEvent.focus(window);
|
||||
await waitFor(() => expect(readText).toHaveBeenCalled());
|
||||
expect(repo.chatgptFinish).not.toHaveBeenCalled();
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
// Pasted by hand it is refused here, and the address this sign-in ended on still goes through.
|
||||
act(() => {
|
||||
result.current.paste(CALLBACK);
|
||||
});
|
||||
expect(result.current.error).toEqual({ messageKey: 'assistant.connect.chatgptWrong', code: 'wrong_url' });
|
||||
const current = CALLBACK.replace('state=s1', 'state=s2');
|
||||
act(() => {
|
||||
result.current.paste(current);
|
||||
});
|
||||
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
|
||||
expect(repo.chatgptFinish).toHaveBeenCalledWith({ flowId: FLOW.flowId, pastedUrl: current });
|
||||
expect(repo.chatgptStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenRouter', () => {
|
||||
@@ -280,4 +326,19 @@ describe('OpenRouter', () => {
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
|
||||
expect(repo.openrouterFinish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-CONNECT-012b: a code OpenRouter added with a second question mark still finishes the sign-in', async () => {
|
||||
rememberOpenrouterFlow(FLOW.flowId);
|
||||
const onConnected = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
useOpenrouterReturn(onConnected);
|
||||
return useLocation();
|
||||
},
|
||||
{ wrapper: wrapperAt('/settings?tab=integrations&ai=openrouter?code=or-code') }
|
||||
);
|
||||
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
|
||||
expect(repo.openrouterFinish).toHaveBeenCalledWith({ flowId: FLOW.flowId, code: 'or-code' });
|
||||
expect(result.current.search).toBe('?tab=integrations');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,15 @@ import { useTranslation } from '../../../i18n';
|
||||
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
|
||||
import { detectKeyKind, isChatgptCallback, providerLabel } from '../../Assistant/providerCatalog';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { connectErrorKey, httpHostOf, refusalCode, rememberOpenrouterFlow, takeOpenrouterFlow } from './aiConnectModel';
|
||||
import {
|
||||
callbackMatchesFlow,
|
||||
connectErrorKey,
|
||||
httpHostOf,
|
||||
openrouterReturnCode,
|
||||
refusalCode,
|
||||
rememberOpenrouterFlow,
|
||||
takeOpenrouterFlow,
|
||||
} from './aiConnectModel';
|
||||
|
||||
/**
|
||||
* The ways to connect a provider, one hook each, behind `AiConnectPanel`: a
|
||||
@@ -205,6 +213,10 @@ export function useChatgptConnect({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<ConnectError | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Set at once, unlike `busy`: the clipboard read on return and the user's own
|
||||
// paste can hand in the same address within one render, and the server takes
|
||||
// a sign-in only once, so the second would come back as expired.
|
||||
const finishing = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
@@ -232,8 +244,8 @@ export function useChatgptConnect({
|
||||
|
||||
const finish = async (raw: string) => {
|
||||
const url = raw.trim();
|
||||
if (!flow || busy) return;
|
||||
if (!isChatgptCallback(url)) {
|
||||
if (!flow || finishing.current) return;
|
||||
if (!isChatgptCallback(url) || !callbackMatchesFlow(url, flow.authorizeUrl)) {
|
||||
setError({ messageKey: 'assistant.connect.chatgptWrong', code: 'wrong_url' });
|
||||
return;
|
||||
}
|
||||
@@ -242,6 +254,7 @@ export function useChatgptConnect({
|
||||
restart();
|
||||
return;
|
||||
}
|
||||
finishing.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -253,6 +266,7 @@ export function useChatgptConnect({
|
||||
setError({ messageKey: connectErrorKey(err), code });
|
||||
if (code === 'expired') restart();
|
||||
} finally {
|
||||
finishing.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
@@ -280,10 +294,12 @@ export function useChatgptConnect({
|
||||
|
||||
// Back from the sign-in window: the address the user just copied is taken
|
||||
// off the clipboard where the browser allows it, else the field has the cursor.
|
||||
// An address left over from an earlier sign-in is not sent: it would use up this one.
|
||||
useOnReturnToTab(opened, () => {
|
||||
if (pasted.trim() || busy) return;
|
||||
readClipboard((text) => {
|
||||
if (!paste(text)) focusField();
|
||||
if (flow && !callbackMatchesFlow(text, flow.authorizeUrl)) focusField();
|
||||
else if (!paste(text)) focusField();
|
||||
}, focusField);
|
||||
});
|
||||
|
||||
@@ -351,9 +367,9 @@ export function useOpenrouterReturn(onConnected: () => void) {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (handled.current || params.get('ai') !== 'openrouter') return;
|
||||
const code = openrouterReturnCode(params);
|
||||
if (handled.current || code === undefined) return;
|
||||
handled.current = true;
|
||||
const code = params.get('code');
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { AiConnectionView, AiStatusResponse } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter, useLocation } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
@@ -61,6 +63,8 @@ function renderConnections(phone = false) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.status.mockResolvedValue(status());
|
||||
repo.models.mockResolvedValue([
|
||||
{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true },
|
||||
|
||||
@@ -19,7 +19,8 @@ import type { StudioUploader } from './studioUpload'
|
||||
* has a handful of completely different kinds of thing you reach for (which
|
||||
* page, what from the journey, what arrangement, what to draw), and putting
|
||||
* them behind tabs in a single 200px column would make each of them cramped.
|
||||
* An icon rail costs 64px and gives the panel the room a photo browser needs.
|
||||
* An icon rail costs 64px, a little more where a section's name holds a longer
|
||||
* word, and gives the panel the room a photo browser needs.
|
||||
*
|
||||
* Which section is open is the shell's when it says so (`section` and
|
||||
* `onSection`), so the inspector on the other side can open the Theme or the
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { BookDocument } from '@trek/shared';
|
||||
import { normalizeBookDocument } from '@trek/shared';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
@@ -9,11 +11,11 @@ import type { StudioUploader } from '../studioUpload';
|
||||
import { RAIL_SECTIONS, type RailSection } from './railSections';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-006: the rail down the side of Studio
|
||||
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-007: the rail down the side of Studio
|
||||
* and the panel it opens. Every section is a tab that opens its own panel
|
||||
* under one head, the keyboard reaches every tab, a long name wraps inside
|
||||
* the rail, and the shell can hold the open section so the inspector can open
|
||||
* one from the other side.
|
||||
* the rail and only between words, and the shell can hold the open section so
|
||||
* the inspector can open one from the other side.
|
||||
*/
|
||||
|
||||
const SOURCE: JourneySource = { entries: [], photos: [], photoEntries: {} };
|
||||
@@ -54,6 +56,16 @@ function draw(over: { section?: RailSection; onSection?: (s: RailSection) => voi
|
||||
);
|
||||
}
|
||||
|
||||
/** Studio's stylesheet, which jsdom does not apply: its rules are the assertion. */
|
||||
const STUDIO_CSS = readFileSync(resolve(process.cwd(), 'src/styles/studio.css'), 'utf8');
|
||||
|
||||
/** The rule written for exactly this selector, searched in `source` (one media query, say). */
|
||||
function rule(selector: string, source = STUDIO_CSS): string {
|
||||
const at = source.indexOf(`${selector} {`);
|
||||
expect(at, `${selector} missing from studio.css`).toBeGreaterThan(-1);
|
||||
return source.slice(at, source.indexOf('}', at) + 1);
|
||||
}
|
||||
|
||||
/** The head of the panel that is open: the name every panel opens on. */
|
||||
const panelTitle = () => must(document.querySelector('.st-side .st-panel-head .st-what-label')).textContent;
|
||||
|
||||
@@ -168,4 +180,42 @@ describe('the rail', () => {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.stickers' }));
|
||||
expect(screen.getByRole('textbox', { name: 'journey.studio.searchStickers' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-RAIL-007: wraps a name only between words, the rail growing to its longest word instead', () => {
|
||||
// "Elements" in the label's 9.5px Poppins is 50.4px wide and a 52px tab left
|
||||
// it 48px, so overflow-wrap: anywhere cut it into "Element" and "s" wherever
|
||||
// no hyphenation dictionary was at hand. Catalan's "Maquetacions" is 77px and
|
||||
// a larger text size widens every word, so no fixed width holds them all.
|
||||
draw();
|
||||
const selector = '.trek-dash .st-rail-btn .st-rail-label';
|
||||
const names = [...document.querySelectorAll('.st-rail-label')];
|
||||
expect(names).toHaveLength(RAIL_SECTIONS.length);
|
||||
// Every name the rail renders is one the rule reaches; .trek-dash is the shell's root.
|
||||
for (const name of names) expect(name.matches(selector.replace('.trek-dash ', ''))).toBe(true);
|
||||
const label = rule(selector);
|
||||
expect(label).toMatch(/overflow-wrap:\s*normal;/);
|
||||
expect(label).toMatch(/word-break:\s*keep-all;/);
|
||||
expect(label).toMatch(/hyphens:\s*manual;/);
|
||||
expect(label).not.toMatch(/anywhere|break-all|break-word|hyphens:\s*auto/);
|
||||
// The type stays on the caption step the user's text size scales.
|
||||
expect(label).toMatch(/font-size:\s*calc\([\d.]+px \* var\(--fs-scale-caption, 1\)\)/);
|
||||
|
||||
// A tab takes the rail's width, and the rail's column is 64px or its
|
||||
// min-content, which is the longest word, in each of the three layouts.
|
||||
expect(rule('.trek-dash .st-rail-btn')).not.toMatch(/[\s;{]width:/);
|
||||
const columns = [...STUDIO_CSS.matchAll(/\.st-body \{[^}]*?grid-template-columns:\s*([^;]+);/g)].map((m) => m[1]);
|
||||
expect(columns).toEqual([
|
||||
'minmax(64px, min-content) 236px minmax(0, 1fr) 300px',
|
||||
'minmax(64px, min-content) 236px minmax(0, 1fr)',
|
||||
'minmax(64px, min-content) minmax(0, 1fr)',
|
||||
]);
|
||||
|
||||
// Below 1080 the panel floats over the workbench's own cell, so it starts
|
||||
// where the rail ends, not at a fixed 64px from the edge.
|
||||
const at = STUDIO_CSS.indexOf('@media (max-width: 1079px) {');
|
||||
expect(at, 'the narrow layout missing from studio.css').toBeGreaterThan(-1);
|
||||
const narrow = STUDIO_CSS.slice(at, STUDIO_CSS.indexOf('\n}', at));
|
||||
expect(narrow).toContain('.trek-dash .st-side, .trek-dash .st-work-wrap { grid-area: 1 / 2; }');
|
||||
expect(rule('.trek-dash .st-side', narrow)).not.toMatch(/position:\s*absolute|inset-inline-start/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,9 +11,9 @@ import { RAIL_IDS, RAIL_SECTIONS, type RailSection } from './railSections';
|
||||
* A list of tabs to a screen reader and to the keyboard, as it is to the eye:
|
||||
* Tab reaches the open section, the arrows move up and down the rail, Home and
|
||||
* End jump to its ends, and Enter or Space opens a section (studioRoving.ts).
|
||||
* A name longer than the rail is wide wraps onto a second line and breaks
|
||||
* inside a word only when the word alone is wider than the rail; the title
|
||||
* carries it whole either way.
|
||||
* A name longer than the rail is wide wraps onto a second line, only ever
|
||||
* between words: where one word alone is wider, the rail grows to fit it
|
||||
* (studio.css) instead of cutting it. The title carries the name whole.
|
||||
*/
|
||||
export function StudioRail({
|
||||
section,
|
||||
|
||||
@@ -24,9 +24,9 @@ function releaseNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDT
|
||||
introKey: 'rel.intro',
|
||||
featuresLabelKey: 'rel.features.label',
|
||||
features: [
|
||||
{ iconName: 'Database', visual: 'places-api', titleKey: 'rel.f1.title', bodyKey: 'rel.f1.body' },
|
||||
{ iconName: 'Route', visual: 'roadtrip', titleKey: 'rel.f2.title', bodyKey: 'rel.f2.body' },
|
||||
{ iconName: 'MapPin', visual: 'dawarich', titleKey: 'rel.f3.title', bodyKey: 'rel.f3.body' },
|
||||
{ iconName: 'Mountain', visual: 'tours', titleKey: 'rel.f1.title', bodyKey: 'rel.f1.body' },
|
||||
{ iconName: 'BookOpen', visual: 'studio', titleKey: 'rel.f2.title', bodyKey: 'rel.f2.body' },
|
||||
{ iconName: 'Sparkles', visual: 'assistant', titleKey: 'rel.f3.title', bodyKey: 'rel.f3.body' },
|
||||
],
|
||||
note: {
|
||||
eyebrowKey: 'rel.note.eyebrow',
|
||||
@@ -78,9 +78,9 @@ describe('ReleaseNoticeModal', () => {
|
||||
renderModal()
|
||||
expect(screen.getByText('rel.f1.title')).toBeInTheDocument()
|
||||
expect(document.querySelectorAll('.rn-feature')).toHaveLength(3)
|
||||
expect(document.querySelector('.rn-vis-places')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-road')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-trail')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-tours')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-studio')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-assist')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-RN-004: splits the note body on the blank line into separate paragraphs', () => {
|
||||
@@ -194,20 +194,20 @@ describe('ReleaseNoticeModal', () => {
|
||||
const n = releaseNotice()
|
||||
n.release!.features = [
|
||||
...n.release!.features,
|
||||
{ iconName: 'FolderSync', visual: 'docsync', layout: 'wide', titleKey: 'rel.f4.title', bodyKey: 'rel.f4.body' },
|
||||
{ iconName: 'LifeBuoy', visual: 'help', layout: 'wide', titleKey: 'rel.f4.title', bodyKey: 'rel.f4.body' },
|
||||
]
|
||||
renderModal(n)
|
||||
const cards = document.querySelectorAll('.rn-feature')
|
||||
expect(cards).toHaveLength(4)
|
||||
expect(cards[3].classList.contains('rn-feature-wide')).toBe(true)
|
||||
expect([...cards].slice(0, 3).some(c => c.classList.contains('rn-feature-wide'))).toBe(false)
|
||||
// The document sync drawing: the wordmark without its API pill, and the five stores.
|
||||
// The help drawing: the help button and two rows of greetings, each written twice to loop.
|
||||
const wide = cards[3]
|
||||
expect(wide.querySelector('.rn-vis-docs')).not.toBeNull()
|
||||
expect(wide.querySelectorAll('.rn-vis-docs-store')).toHaveLength(5)
|
||||
expect(wide.querySelector('.trek-mark .pill-bg')).toBeNull()
|
||||
// The places card keeps the pill: that one is about the index.
|
||||
expect(cards[0].querySelector('.trek-mark .pill-bg')).not.toBeNull()
|
||||
expect(wide.querySelector('.rn-vis-help-q')).not.toBeNull()
|
||||
expect(wide.querySelectorAll('.rn-vis-hello-row')).toHaveLength(2)
|
||||
expect(wide.querySelectorAll('.rn-vis-hello').length % 2).toBe(0)
|
||||
// The tours card lists the kinds of tour along its foot.
|
||||
expect(cards[0].querySelectorAll('.rn-vis-tours-kind')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('FE-RN-016: the close button belongs to the panel, not the note', () => {
|
||||
|
||||
@@ -1,94 +1,166 @@
|
||||
import React from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { ArrowLeftRight, Car, Landmark, Search, Sparkles } from 'lucide-react';
|
||||
import DawarichIcon from '../shared/DawarichIcon';
|
||||
import { NextcloudIcon, OpenCloudIcon, PaperlessIcon, PapraIcon, SynologyDriveIcon } from '../shared/DocumentProviderIcons';
|
||||
import TrekMark from '../shared/TrekMark';
|
||||
import { Bike, Check, Footprints, Landmark, Plane, Sailboat, Snowflake, Sparkles, Star, TrendingUp } from 'lucide-react';
|
||||
import './releaseNoticeVisuals.css';
|
||||
|
||||
/*
|
||||
* The small pictures on the release cards: pieces of TREK itself, drawn at card
|
||||
* size rather than screenshotted, because a screenshot this small is a blur. They
|
||||
* carry no words, only place names and figures that read the same in every
|
||||
* language, so nothing here needs translating.
|
||||
* carry no words, only place names, greetings and figures that read the same in
|
||||
* every language, so nothing here needs translating. Every colour lives in
|
||||
* releaseNoticeVisuals.css, and every motion stops under reduced motion.
|
||||
*/
|
||||
|
||||
function PlacesApiVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-places">
|
||||
<span className="rn-vis-trekbadge"><TrekMark className="rn-vis-trekmark" /></span>
|
||||
<div className="rn-vis-search">
|
||||
<Search size={11} strokeWidth={2.4} />
|
||||
<span className="rn-vis-typed">Fushimi Inari</span>
|
||||
<span className="rn-vis-caret" />
|
||||
</div>
|
||||
<div className="rn-vis-result">
|
||||
<span className="rn-vis-cat" style={{ background: '#ef4444' }}><Landmark size={10} strokeWidth={2.2} /></span>
|
||||
<span className="rn-vis-place">
|
||||
<span>Fushimi Inari Taisha</span>
|
||||
<small>Kyoto</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// ── Tours: a trail climbing to the summit at dusk, and the kinds of tour under it ──
|
||||
|
||||
// The route and its stops, in the planner's own route blue and marker style.
|
||||
const ROUTE = 'M26 84 C 58 82, 60 44, 96 46 S 136 74, 160 52 S 176 30, 182 28';
|
||||
const FAR_RIDGE = 'M0 66 L20 50 L38 60 L60 38 L84 56 L104 44 L128 58 L152 40 L176 54 L200 46 V112 H0 Z';
|
||||
const MOUNTAIN = 'M0 112 V86 L44 62 L84 28 L98 38 L112 32 L152 68 L200 80 V112 Z';
|
||||
const SNOW = 'M84 28 L73 38 L79 37 L84 42 L89 37.5 L95 37 Z';
|
||||
const MEADOW = 'M0 112 V96 C 30 88, 60 90, 92 94 S 160 82, 200 90 V112 Z';
|
||||
// From the valley up the mountain's flank to the summit, in switchbacks.
|
||||
const TRAIL = 'M24 100 C 40 96, 50 90, 54 84 S 66 78, 70 72 S 64 62, 72 56 S 82 48, 78 42 S 82 34, 84 29';
|
||||
const KINDS = [Footprints, Bike, Sailboat, Snowflake, Landmark];
|
||||
|
||||
function RoadtripVisual() {
|
||||
function ToursVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-road">
|
||||
<svg viewBox="0 0 200 112" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<path className="rn-vis-road-casing" d={ROUTE} />
|
||||
<path className="rn-vis-road-line" d={ROUTE} />
|
||||
<div className="rn-vis rn-vis-tours">
|
||||
<svg className="rn-vis-tours-scene" viewBox="0 0 200 112" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<circle className="rn-vis-tours-sun" cx="160" cy="24" r="9" />
|
||||
<path className="rn-vis-tours-far" d={FAR_RIDGE} />
|
||||
<path className="rn-vis-tours-mountain" d={MOUNTAIN} />
|
||||
<path className="rn-vis-tours-snow" d={SNOW} />
|
||||
<path className="rn-vis-tours-meadow" d={MEADOW} />
|
||||
<path className="rn-vis-tours-trail" d={TRAIL} pathLength={1} />
|
||||
<circle className="rn-vis-tours-walker" r="2.6">
|
||||
<animateMotion dur="5s" begin="1.6s" repeatCount="indefinite" path={TRAIL} />
|
||||
</circle>
|
||||
<g className="rn-vis-tours-flag">
|
||||
<line x1="84" y1="29" x2="84" y2="17" />
|
||||
<path d="M84 17 L93 20 L84 23 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="rn-vis-marker" style={{ left: '13%', top: '75%', background: '#6366f1' }}>1</span>
|
||||
<span className="rn-vis-marker" style={{ left: '48%', top: '41%', background: '#f59e0b' }}>2</span>
|
||||
<span className="rn-vis-marker" style={{ left: '91%', top: '25%', background: '#ec4899' }}>3</span>
|
||||
<span className="rn-vis-distance"><Car size={10} strokeWidth={2.2} />386 km</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A recorded day, with the two places the traveller stayed.
|
||||
const TRAIL = '84,94 96,88 106,80 118,76 130,74 142,66 152,58 162,62 172,66 180,56 186,46';
|
||||
const STAYS: Array<[number, number]> = [[130, 74], [186, 46]];
|
||||
|
||||
function DawarichVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-trail">
|
||||
<svg viewBox="0 0 200 112" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<polyline className="rn-vis-trail-line" points={TRAIL} />
|
||||
{STAYS.map(([x, y]) => <circle key={x} className="rn-vis-trail-stay" cx={x} cy={y} r="3.4" />)}
|
||||
</svg>
|
||||
<span className="rn-vis-brand"><DawarichIcon size={58} /></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A trip's documents travelling both ways between TREK and the five stores. The
|
||||
// wordmark without the API pill: this is TREK itself, not its index.
|
||||
const STORES = [PaperlessIcon, PapraIcon, NextcloudIcon, OpenCloudIcon, SynologyDriveIcon];
|
||||
|
||||
function DocSyncVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-docs">
|
||||
<span className="rn-vis-trekbadge"><TrekMark pill={false} className="rn-vis-trekmark" /></span>
|
||||
<span className="rn-vis-docs-flow"><ArrowLeftRight size={14} strokeWidth={2.4} /></span>
|
||||
<div className="rn-vis-docs-stores">
|
||||
{STORES.map((Store, i) => (
|
||||
<span key={i} className="rn-vis-docs-store"><Store size={22} /></span>
|
||||
<span className="rn-vis-tours-climb">
|
||||
<TrendingUp size={9} strokeWidth={2.6} />
|
||||
860 m
|
||||
</span>
|
||||
<div className="rn-vis-tours-kinds">
|
||||
{KINDS.map((Kind, i) => (
|
||||
<span key={i} className="rn-vis-tours-kind" style={{ animationDelay: `${1.2 + i * 0.08}s` }}>
|
||||
<Kind size={10} strokeWidth={2.2} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TREK Studio: an open photo book with a polaroid, a stamp and a sticker ──
|
||||
|
||||
function StudioVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-studio">
|
||||
<div className="rn-vis-book">
|
||||
<div className="rn-vis-page rn-vis-page-start">
|
||||
<div className="rn-vis-polaroid">
|
||||
<svg viewBox="0 0 60 44" aria-hidden="true">
|
||||
<rect className="rn-vis-sky" width="60" height="44" />
|
||||
<circle className="rn-vis-sun" cx="42" cy="15" r="6" />
|
||||
<path className="rn-vis-peak-far" d="M0 32 L12 20 L20 26 L32 12 L44 24 L52 18 L60 26 V44 H0 Z" />
|
||||
<path className="rn-vis-peak-near" d="M0 38 L14 28 L26 34 L38 26 L60 36 V44 H0 Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="rn-vis-tape" />
|
||||
</div>
|
||||
<div className="rn-vis-page rn-vis-page-end">
|
||||
<span className="rn-vis-book-title">Dolomiti</span>
|
||||
<span className="rn-vis-book-line" />
|
||||
<span className="rn-vis-book-line rn-vis-book-line-short" />
|
||||
<span className="rn-vis-stamp">
|
||||
<Plane size={10} strokeWidth={2.2} />
|
||||
</span>
|
||||
</div>
|
||||
<span className="rn-vis-gutter" />
|
||||
</div>
|
||||
<span className="rn-vis-sticker">
|
||||
<Star size={15} strokeWidth={1.8} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── The assistant: a day in Porto proposed stop by stop, ready to apply ──
|
||||
|
||||
const PORTO: Array<{ time: string; name: string; tone: string }> = [
|
||||
{ time: '09:30', name: 'Ribeira', tone: 'sight' },
|
||||
{ time: '12:30', name: 'Livraria Lello', tone: 'culture' },
|
||||
{ time: '16:00', name: 'Torre dos Clérigos', tone: 'view' },
|
||||
];
|
||||
|
||||
function AssistantVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-assist">
|
||||
<div className="rn-vis-assist-head">
|
||||
<span className="rn-vis-assist-avatar">
|
||||
<Sparkles size={10} strokeWidth={2.4} />
|
||||
</span>
|
||||
<span className="rn-vis-assist-typing">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</div>
|
||||
<div className="rn-vis-assist-card">
|
||||
{PORTO.map((stop, i) => (
|
||||
<div key={stop.name} className="rn-vis-assist-row" style={{ animationDelay: `${0.5 + i * 0.35}s` }}>
|
||||
<span className="rn-vis-assist-time">{stop.time}</span>
|
||||
<span className={`rn-vis-assist-dot rn-vis-assist-dot-${stop.tone}`} />
|
||||
<span className="rn-vis-assist-name">{stop.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="rn-vis-assist-apply">
|
||||
<Check size={11} strokeWidth={3} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Help on every screen, in 27 languages: hello in each of them, drifting by ──
|
||||
|
||||
const HELLO_A = ['Hallo', 'Bonjour', 'Hola', 'Ciao', 'Olá', 'Hej', 'Ahoj', 'Cześć', 'Szia', 'Tere', 'Salam', 'Bon dia', 'Halo'];
|
||||
const HELLO_B = ['Привіт', 'Привет', 'Γεια σου', 'مرحبا', 'こんにちは', '안녕하세요', '你好', '哈囉', 'สวัสดี', 'Xin chào', 'Merhaba', 'Dobrý deň'];
|
||||
|
||||
function HelloRow({ words, reverse }: { words: string[]; reverse?: boolean }) {
|
||||
// Written twice, so the row can slide by half its width and start over without a seam.
|
||||
return (
|
||||
<div className={reverse ? 'rn-vis-hello-row rn-vis-hello-row-reverse' : 'rn-vis-hello-row'}>
|
||||
{[...words, ...words].map((word, i) => (
|
||||
<span key={i} className="rn-vis-hello" lang="und">
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-help">
|
||||
<span className="rn-vis-help-q">?</span>
|
||||
<div className="rn-vis-help-langs">
|
||||
<HelloRow words={HELLO_A} />
|
||||
<HelloRow words={HELLO_B} reverse />
|
||||
</div>
|
||||
<span className="rn-vis-help-count">27</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const VISUALS: Record<string, () => React.ReactElement> = {
|
||||
'places-api': PlacesApiVisual,
|
||||
roadtrip: RoadtripVisual,
|
||||
dawarich: DawarichVisual,
|
||||
docsync: DocSyncVisual,
|
||||
tours: ToursVisual,
|
||||
studio: StudioVisual,
|
||||
assistant: AssistantVisual,
|
||||
help: HelpVisual,
|
||||
};
|
||||
|
||||
/** The card's picture, or its icon when the release names no drawing this client knows. */
|
||||
|
||||
@@ -380,223 +380,6 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── The card pictures ─────────────────────────────────────────────────── */
|
||||
|
||||
.rn-vis {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: #f5f5f7;
|
||||
font-family: var(--font-system);
|
||||
}
|
||||
|
||||
.rn-vis-road > svg,
|
||||
.rn-vis-trail > svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
|
||||
.rn-vis-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: oklch(1 0 0 / 0.55);
|
||||
}
|
||||
|
||||
/* Places API: the index answering a search while it is typed. */
|
||||
.rn-vis-places {
|
||||
padding: 9px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* The same wordmark the API keys card leads with, centred; its pill text inverts against the tile. */
|
||||
.rn-vis-trekbadge { align-self: center; display: flex; align-items: center; height: 24px; }
|
||||
|
||||
.rn-vis-trekmark { display: block; height: 20px; width: auto; color: #f5f5f7; }
|
||||
|
||||
.rn-vis-places .trek-mark .pill-fg { fill: #101013; }
|
||||
|
||||
.rn-vis-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 8px;
|
||||
background: oklch(1 0 0 / 0.08);
|
||||
border: 1px solid oklch(1 0 0 / 0.16);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rn-vis-search svg { width: 11px; height: 11px; flex: none; color: oklch(1 0 0 / 0.55); }
|
||||
|
||||
/* The query types itself out once, then the answer arrives under it. */
|
||||
.rn-vis-typed {
|
||||
display: inline-block;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
animation: rn-type 1.3s steps(13, end) 0.7s both;
|
||||
}
|
||||
|
||||
@keyframes rn-type { from { max-width: 0; } to { max-width: 90px; } }
|
||||
|
||||
.rn-vis-caret {
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
margin-inline-start: -4px;
|
||||
background: #f5f5f7;
|
||||
animation: rn-caret 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes rn-caret { 50% { opacity: 0; } }
|
||||
|
||||
.rn-vis-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
background: oklch(1 0 0 / 0.07);
|
||||
animation: rn-answer 0.3s ease 2.1s both;
|
||||
}
|
||||
|
||||
@keyframes rn-answer { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.rn-vis-cat {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid #ffffff;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rn-vis-place { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
|
||||
|
||||
.rn-vis-place span {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rn-vis-place small { font-size: 8.5px; color: oklch(1 0 0 / 0.5); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rn-vis-typed, .rn-vis-caret, .rn-vis-result { animation: none; }
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-typed,
|
||||
[data-reduce-motion='true'] .rn-vis-caret,
|
||||
[data-reduce-motion='true'] .rn-vis-result { animation: none; }
|
||||
|
||||
/* Road trip: the drive on the map, in the planner's route blue and marker style. */
|
||||
.rn-vis-road-casing { fill: none; stroke: #0a5cc2; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.rn-vis-road-line { fill: none; stroke: #0a84ff; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.rn-vis-marker {
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -9px 0 0 -9px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 1px 4px oklch(0 0 0 / 0.35);
|
||||
color: #ffffff;
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rn-vis-distance {
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 99px;
|
||||
background: #f5f5f7;
|
||||
color: #101013;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Dawarich: a recorded day, in the trail colour the map draws it with. */
|
||||
.rn-vis-trail-line { fill: none; stroke: #2563eb; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.rn-vis-trail-stay { fill: #ffffff; stroke: #2563eb; stroke-width: 2; }
|
||||
|
||||
/* Document sync: the wordmark, the two-way arrow under it, and the five stores in a row. */
|
||||
.rn-vis-docs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.rn-vis-docs .rn-vis-trekbadge { height: 22px; }
|
||||
.rn-vis-docs .rn-vis-trekmark { height: 19px; }
|
||||
|
||||
.rn-vis-docs-flow { display: flex; color: oklch(1 0 0 / 0.55); transform: rotate(90deg); }
|
||||
|
||||
.rn-vis-docs-stores {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rn-vis-docs-store {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: oklch(1 0 0 / 0.1);
|
||||
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.18), inset 0 0 0 1px oklch(1 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* The mark itself, unframed, with its corners rounded like an app icon. */
|
||||
.rn-vis-brand {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px oklch(0 0 0 / 0.45), 0 0 0 1px oklch(1 0 0 / 0.14);
|
||||
}
|
||||
|
||||
/* A glass sheen over the mark: a soft light from the top and a bright inner edge. */
|
||||
.rn-vis-brand::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(160deg, oklch(1 0 0 / 0.42) 0%, oklch(1 0 0 / 0.08) 38%, transparent 55%, oklch(0 0 0 / 0.12) 100%);
|
||||
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.55), inset 0 0 0 1px oklch(1 0 0 / 0.22);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rn-vis-brand img { display: block; width: 100%; height: 100%; }
|
||||
|
||||
/* ── Right: the note ───────────────────────────────────────────────────── */
|
||||
|
||||
.rn-note {
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
/*
|
||||
* The pictures on the release cards (ReleaseNoticeVisuals.tsx). They sit on the
|
||||
* release half, which is dark in both themes, so they draw in their own colours
|
||||
* rather than the surface tokens, like the panel around them.
|
||||
*/
|
||||
|
||||
/* ── The card pictures ─────────────────────────────────────────────────── */
|
||||
|
||||
.rn-vis {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: #f5f5f7;
|
||||
font-family: var(--font-system);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rn-vis-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: oklch(1 0 0 / 0.55);
|
||||
}
|
||||
|
||||
@keyframes rn-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
@keyframes rn-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes rn-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes rn-typing {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
transform: none;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
@keyframes rn-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 oklch(0.72 0.19 150 / 0.55);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 9px oklch(0.72 0.19 150 / 0);
|
||||
}
|
||||
}
|
||||
@keyframes rn-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
@keyframes rn-sticker {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.2) rotate(-50deg);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: scale(1.12) rotate(12deg);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tours: a trail climbing to the summit at dusk, and the kinds of tour along the foot. */
|
||||
.rn-vis-tours {
|
||||
background: linear-gradient(180deg, #24304f 0%, #3c4a6b 46%, #c9876a 100%);
|
||||
}
|
||||
|
||||
.rn-vis-tours-scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rn-vis-tours-sun {
|
||||
fill: #fde68a;
|
||||
filter: drop-shadow(0 0 6px oklch(0.92 0.12 90 / 0.7));
|
||||
}
|
||||
|
||||
.rn-vis-tours-far {
|
||||
fill: #56668a;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.rn-vis-tours-mountain {
|
||||
fill: #23344a;
|
||||
}
|
||||
|
||||
.rn-vis-tours-snow {
|
||||
fill: #e8f0f8;
|
||||
}
|
||||
|
||||
.rn-vis-tours-meadow {
|
||||
fill: #14202d;
|
||||
}
|
||||
|
||||
.rn-vis-tours-trail {
|
||||
fill: none;
|
||||
stroke: #fbbf24;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
filter: drop-shadow(0 0 2px oklch(0.85 0.16 80 / 0.6));
|
||||
animation: rn-draw 1.3s ease-in-out 0.3s both;
|
||||
}
|
||||
|
||||
.rn-vis-tours-walker {
|
||||
fill: #ffffff;
|
||||
filter: drop-shadow(0 0 3px #ffffff);
|
||||
opacity: 0;
|
||||
animation: rn-show 0.2s linear 1.6s both;
|
||||
}
|
||||
|
||||
@keyframes rn-show {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.rn-vis-tours-flag {
|
||||
transform-box: fill-box;
|
||||
transform-origin: 0% 100%;
|
||||
animation: rn-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 1.5s both;
|
||||
}
|
||||
|
||||
.rn-vis-tours-flag line {
|
||||
stroke: #f5f5f7;
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.rn-vis-tours-flag path {
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
.rn-vis-tours-climb {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-start: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 99px;
|
||||
background: oklch(1 0 0 / 0.14);
|
||||
border: 1px solid oklch(1 0 0 / 0.18);
|
||||
backdrop-filter: blur(6px);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rn-vis-tours-kinds {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 7px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.rn-vis-tours-kind {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: oklch(1 0 0 / 0.12);
|
||||
border: 1px solid oklch(1 0 0 / 0.2);
|
||||
backdrop-filter: blur(6px);
|
||||
animation: rn-rise 0.3s ease both;
|
||||
}
|
||||
|
||||
/* TREK Studio: an open book, a polaroid taped in, a title, a stamp and a sticker. */
|
||||
.rn-vis-studio {
|
||||
background:
|
||||
radial-gradient(120% 90% at 50% 115%, oklch(0.62 0.14 45 / 0.38) 0%, transparent 60%),
|
||||
linear-gradient(165deg, #23201c 0%, #171513 100%);
|
||||
}
|
||||
|
||||
.rn-vis-book {
|
||||
position: absolute;
|
||||
inset: 13px 20px 13px;
|
||||
display: flex;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
transform: perspective(520px) rotateX(16deg);
|
||||
box-shadow:
|
||||
0 16px 22px -10px oklch(0 0 0 / 0.75),
|
||||
0 0 0 1px oklch(1 0 0 / 0.08);
|
||||
}
|
||||
|
||||
.rn-vis-page {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.rn-vis-page-start {
|
||||
background: #ede2c9;
|
||||
}
|
||||
|
||||
.rn-vis-page-end {
|
||||
background: #f6eedc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 11px 9px 0;
|
||||
}
|
||||
|
||||
.rn-vis-gutter {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 50%;
|
||||
width: 16px;
|
||||
margin-inline-start: -8px;
|
||||
background: linear-gradient(90deg, transparent, oklch(0 0 0 / 0.2), transparent);
|
||||
}
|
||||
|
||||
.rn-vis-polaroid {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
inset-inline-start: 10px;
|
||||
width: 58%;
|
||||
padding: 3px 3px 9px;
|
||||
background: #ffffff;
|
||||
transform: rotate(-6deg);
|
||||
box-shadow: 0 3px 6px oklch(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.rn-vis-polaroid svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.rn-vis-sky {
|
||||
fill: #fcd5b0;
|
||||
}
|
||||
.rn-vis-sun {
|
||||
fill: #fb923c;
|
||||
}
|
||||
.rn-vis-peak-far {
|
||||
fill: #7c8aa0;
|
||||
}
|
||||
.rn-vis-peak-near {
|
||||
fill: #334155;
|
||||
}
|
||||
|
||||
.rn-vis-tape {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
inset-inline-start: 30%;
|
||||
width: 24px;
|
||||
height: 7px;
|
||||
transform: rotate(-24deg);
|
||||
background: repeating-linear-gradient(45deg, #f43f5e 0 3px, #fecdd3 3px 6px);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.rn-vis-book-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: #1f3d2b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rn-vis-book-line {
|
||||
height: 3px;
|
||||
width: 88%;
|
||||
border-radius: 2px;
|
||||
background: oklch(0.4 0.03 60 / 0.3);
|
||||
}
|
||||
.rn-vis-book-line-short {
|
||||
width: 58%;
|
||||
}
|
||||
|
||||
.rn-vis-stamp {
|
||||
position: absolute;
|
||||
bottom: 9px;
|
||||
inset-inline-end: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px dashed #b45309;
|
||||
color: #b45309;
|
||||
transform: rotate(-14deg);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.rn-vis-sticker {
|
||||
position: absolute;
|
||||
bottom: 7px;
|
||||
inset-inline-start: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #facc15;
|
||||
color: #fff8db;
|
||||
box-shadow:
|
||||
0 0 0 2px #ffffff,
|
||||
0 4px 10px oklch(0 0 0 / 0.4);
|
||||
animation: rn-sticker 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 1s both;
|
||||
}
|
||||
|
||||
.rn-vis-sticker svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* The assistant: a day in Porto proposed stop by stop, and the button that applies it. */
|
||||
.rn-vis-assist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 9px 10px;
|
||||
background:
|
||||
radial-gradient(110% 80% at 0% 0%, oklch(0.55 0.2 290 / 0.36) 0%, transparent 62%),
|
||||
linear-gradient(165deg, #1d1b29 0%, #141320 100%);
|
||||
}
|
||||
|
||||
.rn-vis-assist-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rn-vis-assist-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #a78bfa 0%, #3b82f6 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 8px oklch(0.55 0.2 290 / 0.6);
|
||||
}
|
||||
|
||||
.rn-vis-assist-typing {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 99px;
|
||||
background: oklch(1 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.rn-vis-assist-typing i {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #f5f5f7;
|
||||
animation: rn-typing 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rn-vis-assist-typing i:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.rn-vis-assist-typing i:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.rn-vis-assist-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 9px;
|
||||
background: oklch(1 0 0 / 0.08);
|
||||
border: 1px solid oklch(1 0 0 / 0.14);
|
||||
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.12);
|
||||
}
|
||||
|
||||
.rn-vis-assist-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.35;
|
||||
animation: rn-rise 0.34s ease both;
|
||||
}
|
||||
|
||||
.rn-vis-assist-time {
|
||||
flex: none;
|
||||
width: 27px;
|
||||
color: oklch(1 0 0 / 0.5);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rn-vis-assist-dot {
|
||||
flex: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.rn-vis-assist-dot-sight {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.rn-vis-assist-dot-culture {
|
||||
background: #a78bfa;
|
||||
}
|
||||
.rn-vis-assist-dot-view {
|
||||
background: #34d399;
|
||||
}
|
||||
.rn-vis-assist-name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rn-vis-assist-apply {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-end: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
color: #ffffff;
|
||||
animation:
|
||||
rn-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) 1.7s both,
|
||||
rn-ring 2.2s ease-out 2s infinite;
|
||||
}
|
||||
|
||||
/* Help on every screen: the help button, and hello in TREK's languages drifting past it. */
|
||||
.rn-vis-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
background:
|
||||
radial-gradient(70% 120% at 10% 50%, oklch(0.6 0.16 250 / 0.34) 0%, transparent 70%),
|
||||
linear-gradient(165deg, #1a1f2b 0%, #12151d 100%);
|
||||
}
|
||||
|
||||
.rn-vis-help-q {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: #f5f5f7;
|
||||
color: #101013;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
box-shadow:
|
||||
0 0 0 5px oklch(1 0 0 / 0.08),
|
||||
0 6px 16px oklch(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
.rn-vis-help-count {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
inset-inline-start: 42px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 99px;
|
||||
background: #3b82f6;
|
||||
color: #ffffff;
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
box-shadow: 0 0 0 2px #12151d;
|
||||
}
|
||||
|
||||
/* The rows slide the same way in every language: they are a picture, not text. */
|
||||
.rn-vis-help-langs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
direction: ltr;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 14%, #000 86%, transparent 100%);
|
||||
mask-image: linear-gradient(90deg, transparent 0%, #000 14%, #000 86%, transparent 100%);
|
||||
}
|
||||
|
||||
.rn-vis-hello-row {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: rn-marquee 34s linear infinite;
|
||||
}
|
||||
.rn-vis-hello-row-reverse {
|
||||
animation-direction: reverse;
|
||||
animation-duration: 40s;
|
||||
}
|
||||
|
||||
.rn-vis-hello {
|
||||
flex: none;
|
||||
margin-inline-end: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 99px;
|
||||
background: oklch(1 0 0 / 0.09);
|
||||
border: 1px solid oklch(1 0 0 / 0.13);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rn-vis-tours-trail,
|
||||
.rn-vis-tours-flag,
|
||||
.rn-vis-tours-kind,
|
||||
.rn-vis-sticker,
|
||||
.rn-vis-assist-typing i,
|
||||
.rn-vis-assist-row,
|
||||
.rn-vis-assist-apply,
|
||||
.rn-vis-hello-row {
|
||||
animation: none;
|
||||
}
|
||||
.rn-vis-tours-trail {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
.rn-vis-tours-walker {
|
||||
display: none;
|
||||
}
|
||||
.rn-vis-sticker {
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-tours-trail,
|
||||
[data-reduce-motion='true'] .rn-vis-tours-flag,
|
||||
[data-reduce-motion='true'] .rn-vis-tours-kind,
|
||||
[data-reduce-motion='true'] .rn-vis-sticker,
|
||||
[data-reduce-motion='true'] .rn-vis-assist-typing i,
|
||||
[data-reduce-motion='true'] .rn-vis-assist-row,
|
||||
[data-reduce-motion='true'] .rn-vis-assist-apply,
|
||||
[data-reduce-motion='true'] .rn-vis-hello-row {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-tours-trail {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-tours-walker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-sticker {
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { DOCUMENT_PROVIDER_IDS } from '@trek/shared'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from './DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from './documentProviderIconMap'
|
||||
import TrekIcon from './TrekIcon'
|
||||
|
||||
const marks = Object.entries(DOCUMENT_PROVIDER_ICONS)
|
||||
|
||||
@@ -15,7 +15,7 @@ import React from 'react'
|
||||
* for legibility in a monochrome row.
|
||||
*/
|
||||
|
||||
interface GlyphProps {
|
||||
export interface GlyphProps {
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
@@ -126,12 +126,3 @@ export function SynologyDriveIcon({ size = 20, className }: GlyphProps): React.R
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Keyed by the provider ids in `document_providers`. */
|
||||
export const DOCUMENT_PROVIDER_ICONS: Record<string, React.ComponentType<GlyphProps>> = {
|
||||
paperless: PaperlessIcon,
|
||||
papra: PapraIcon,
|
||||
nextcloud: NextcloudIcon,
|
||||
opencloud: OpenCloudIcon,
|
||||
synologydrive: SynologyDriveIcon,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
type GlyphProps,
|
||||
NextcloudIcon,
|
||||
OpenCloudIcon,
|
||||
PaperlessIcon,
|
||||
PapraIcon,
|
||||
SynologyDriveIcon,
|
||||
} from './DocumentProviderIcons';
|
||||
|
||||
/**
|
||||
* The document providers' marks keyed by the provider ids in `document_providers`.
|
||||
* Kept apart from the drawings so that module exports components only.
|
||||
*/
|
||||
export const DOCUMENT_PROVIDER_ICONS: Record<string, React.ComponentType<GlyphProps>> = {
|
||||
paperless: PaperlessIcon,
|
||||
papra: PapraIcon,
|
||||
nextcloud: NextcloudIcon,
|
||||
opencloud: OpenCloudIcon,
|
||||
synologydrive: SynologyDriveIcon,
|
||||
};
|
||||
@@ -70,6 +70,19 @@ interface CachedTourDraft {
|
||||
draft: TourDraft;
|
||||
}
|
||||
|
||||
/**
|
||||
* A road route as the router answered it, kept on the device so a trip opens
|
||||
* with its roads drawn instead of asking the router for every leg again (#2794).
|
||||
* `key` is RouteCalculator's cache key (profile, unit, waypoints, avoided
|
||||
* roads), `route` its answer as it came, `savedAt` when it came: an old answer
|
||||
* is asked again, and the oldest give way first.
|
||||
*/
|
||||
interface CachedRoute {
|
||||
key: string;
|
||||
route: unknown;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
// ── Queue + sync types ────────────────────────────────────────────────────────
|
||||
|
||||
// 'conflict' is terminal-until-resolved: the server rejected the replay because
|
||||
@@ -279,6 +292,7 @@ export class TrekOfflineDb extends Dexie {
|
||||
ferrataTiles!: Table<CachedFerrataTile, [number, string]>;
|
||||
tourStationVisits!: Table<CachedTourStationVisit, [number, string]>;
|
||||
tourDrafts!: Table<CachedTourDraft, number>;
|
||||
routeCache!: Table<CachedRoute, string>;
|
||||
|
||||
constructor(name: string = ANON_DB_NAME) {
|
||||
super(name);
|
||||
@@ -366,6 +380,11 @@ export class TrekOfflineDb extends Dexie {
|
||||
// v13: the Tours planner's unsaved draft as the account last said it, one
|
||||
// per trip (the database is the signed-in user's own), so it opens offline.
|
||||
this.version(13).stores({ tourDrafts: 'tripId' });
|
||||
|
||||
// v14: road routes the router answered, by their cache key, so a trip with its
|
||||
// whole route shown opens drawn instead of re-routing every leg (#2794). Not per
|
||||
// trip: the same waypoints are the same road in any trip; `savedAt` to prune.
|
||||
this.version(14).stores({ routeCache: 'key, savedAt' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import DawarichIcon from '../../../components/shared/DawarichIcon'
|
||||
import AirTrailIcon from '../../../components/shared/AirTrailIcon'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../components/shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../components/shared/documentProviderIconMap'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import {
|
||||
type Addon,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import type { AssistantChatTrip } from '../../../components/Assistant/assistantViewContext';
|
||||
import type { EntityTarget } from '../../../components/Assistant/cards/cardTypes';
|
||||
import { chatPagePath, useAssistantHome, useRouteJournalChat } from '../../../components/Assistant/useAssistantHome';
|
||||
import { useAssistantStatus } from '../../../components/Assistant/useAssistantStatus';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { AssistantScreen, type MapHops } from '../trip/assistant/MAssistantScreen';
|
||||
|
||||
@@ -26,7 +27,6 @@ export default function MAssistantHomeScreen() {
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open);
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const close = useAssistantStore((s) => s.close);
|
||||
|
||||
// The trip's screen (or the journal's) takes over with the window still open, so its chat comes up there.
|
||||
@@ -46,19 +46,21 @@ export default function MAssistantHomeScreen() {
|
||||
[close, navigate]
|
||||
);
|
||||
|
||||
// The desktop window loads this itself; on the phone the pages outside a trip ask once.
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
// The desktop window loads this itself; on the phone the pages outside a trip ask here.
|
||||
useAssistantStatus();
|
||||
|
||||
if (inTrip || !available || !open || minimized) return null;
|
||||
// Mounted beside MobileShell, not inside it, so the --m-* tokens the screen paints
|
||||
// with only resolve under an m-root of its own; without it the chat is see-through.
|
||||
return (
|
||||
<AssistantScreen
|
||||
tripId={chat.id}
|
||||
tripTitle={chat.title}
|
||||
hidden={false}
|
||||
hops={hops}
|
||||
onOpenTripChat={journal ? undefined : home.openTripChat}
|
||||
/>
|
||||
<div className="m-root">
|
||||
<AssistantScreen
|
||||
tripId={chat.id}
|
||||
tripTitle={chat.title}
|
||||
hidden={false}
|
||||
hops={hops}
|
||||
onOpenTripChat={journal ? undefined : home.openTripChat}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
|
||||
@@ -47,6 +48,8 @@ function status(over: Partial<AiStatusResponse> = {}): AiStatusResponse {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.models.mockResolvedValue([
|
||||
{ id: 'gemini-flash', label: 'Gemini Flash', supportsTools: true, free: true },
|
||||
{ id: 'gemini-pro', label: 'Gemini Pro', supportsTools: true },
|
||||
|
||||
@@ -23,6 +23,7 @@ import RoutePeekLazy from '../../../../components/Assistant/map/RoutePeekLazy';
|
||||
import { moveRoutePeek } from '../../../../components/Assistant/map/routePeekControl';
|
||||
import { useAssistantHintsSetting } from '../../../../components/Assistant/useAssistantHints';
|
||||
import { useAssistantLiveStep } from '../../../../components/Assistant/useAssistantLiveStep';
|
||||
import { useAssistantStatus } from '../../../../components/Assistant/useAssistantStatus';
|
||||
import { useAssistantThread } from '../../../../components/Assistant/useAssistantThread';
|
||||
import { useTripMemory } from '../../../../components/Assistant/useTripMemory';
|
||||
import { useKeyboardInset } from '../../../../hooks/useKeyboardInset';
|
||||
@@ -395,13 +396,10 @@ export default function MAssistantScreen({ planner, shell }: MTripSheetsProps) {
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const peeking = useAssistantStore((s) => s.routePeek?.tripId === planner.tripId);
|
||||
const running = useAssistantStore((s) => s.byTrip[planner.tripId]?.isRunning === true);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const hops = useMapHops({ planner, shell });
|
||||
|
||||
// The desktop window loads this itself; on the phone the trip screen asks once.
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
// The desktop window loads this itself; on the phone the trip screen asks here.
|
||||
useAssistantStatus();
|
||||
|
||||
if (!available || !open || !planner.trip) return null;
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill, selectedDayDate,
|
||||
} from '../../../../components/Planner/reservationFormModel'
|
||||
import { useReservationForm } from '../../../../components/Planner/useReservationForm'
|
||||
import StayPinStatus from '../../../../components/Planner/StayPinStatus'
|
||||
import { buildAssignmentOptions } from '../../../../components/Planner/assignmentOptions'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
|
||||
@@ -60,7 +61,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
const {
|
||||
form, setForm, set, isSaving, setIsSaving, pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler,
|
||||
isEndBeforeStart, endTimeBeforeStartTime, dateBounds, startDate, startTime, setStartDate, setStartTime, takeStopDay,
|
||||
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, saveData: buildSaveData,
|
||||
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, stayPin, saveData: buildSaveData,
|
||||
} = useReservationForm(EMPTY, days, places)
|
||||
const assignmentOptions = useMemo(
|
||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||
@@ -85,6 +86,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
expense.reset()
|
||||
setPendingFiles([])
|
||||
setTravelerIds(travelerIdsOf(editingReservation))
|
||||
stayPin.reset()
|
||||
|
||||
const res = editingReservation
|
||||
if (res) {
|
||||
@@ -93,6 +95,8 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
const pf = reservationPrefill
|
||||
setForm({ ...EMPTY, ...reservationFieldsFromPrefill(pf, days, false) })
|
||||
setPendingFiles(pf._sourceFiles ?? [])
|
||||
// Find an imported stay on the map now, so the review says whether it will show there.
|
||||
if (pf.type === 'hotel') stayPin.lookUp(pf._venue?.name || pf.title, pf._venue?.address)
|
||||
} else {
|
||||
// Opened from a day's toolbar: start on that day rather than on a blank
|
||||
// date the user has to look up again (#1998). A hotel spans to the next
|
||||
@@ -335,6 +339,13 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
placeholder={t('reservations.locationPlaceholder')}
|
||||
className={FIELD_CLS}
|
||||
/>
|
||||
<div className="mt-2 empty:hidden">
|
||||
<StayPinStatus
|
||||
pin={stayPin.pin}
|
||||
hidden={!!form.hotel_place_id}
|
||||
onRetry={() => stayPin.lookUp(form.title, form.hotel_address)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import MToggle from '../../../components/MToggle'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import { TileHeader } from '../sheets/MTripSheetUi'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/documentProviderIconMap'
|
||||
import TrekIcon from '../../../../components/shared/TrekIcon'
|
||||
import {
|
||||
bindingNotice, needsReauth, storeName, useDocSync, type DocSyncLink, type DocSyncProvider, type DocSyncVerdict,
|
||||
|
||||
@@ -281,7 +281,8 @@ export function TourPlannerMapStage({
|
||||
) : (
|
||||
<MapView
|
||||
tripId={tripId}
|
||||
places={[]}
|
||||
places={tourMap.overviewPlaces}
|
||||
onMarkerClick={tourMap.openOverviewTour}
|
||||
route={lines.route}
|
||||
routeWalking={lines.routeWalking}
|
||||
routeColors={lines.routeColors}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { Translate } from './plannerTypes'
|
||||
import { useBookingEdits } from './useBookingEdits'
|
||||
|
||||
type Options = Parameters<typeof useBookingEdits>[0]
|
||||
type ImportedStay = { place_id?: number; venue?: { name?: string; address?: string | null }; address?: string }
|
||||
type ImportedStay = { place_id?: number; venue?: { name?: string; address?: string | null; lat?: number; lng?: number }; address?: string }
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
|
||||
const t = ((key: string) => key) as Translate
|
||||
@@ -231,6 +231,32 @@ describe('useBookingEdits', () => {
|
||||
expect(actions.addReservation).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('FE-TP-BOOKING-007b: a stay lands where the review found it, falls back to its name, and says when it has no spot', async () => {
|
||||
const { result } = renderEdits()
|
||||
|
||||
// The review already found it: no second search, the place sits right there.
|
||||
vi.mocked(mapsApi.search).mockClear()
|
||||
await saveHotel(result, { venue: { name: 'Gracery', address: '1-19-1 Kabukicho', lat: 35.69, lng: 139.7 } })
|
||||
expect(mapsApi.search).not.toHaveBeenCalled()
|
||||
expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Gracery', lat: 35.69, lng: 139.7, address: '1-19-1 Kabukicho' })
|
||||
expect(toast.warning).not.toHaveBeenCalled()
|
||||
|
||||
// A romanized address that matches nothing no longer sinks the hotel's name.
|
||||
vi.mocked(mapsApi.search).mockImplementation(async (query: string) =>
|
||||
(query === 'Gracery' ? { places: [{ lat: 35.69, lng: 139.7 }] } : { places: [] }) as never)
|
||||
await saveHotel(result, { venue: { name: 'Gracery', address: '1-19-1 Kabukicho' } })
|
||||
expect(vi.mocked(mapsApi.search).mock.calls.map(([q]) => q)).toEqual(['Gracery 1-19-1 Kabukicho', 'Gracery'])
|
||||
expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Gracery', lat: 35.69, lng: 139.7, address: '1-19-1 Kabukicho' })
|
||||
expect(toast.warning).not.toHaveBeenCalled()
|
||||
|
||||
// Found nowhere: the booking is still saved, and the reader is told it has no pin.
|
||||
vi.mocked(mapsApi.search).mockResolvedValue({ places: [] } as never)
|
||||
await saveHotel(result, { venue: { name: 'Nowhere Inn', address: 'Nowhere 1' } })
|
||||
expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Nowhere Inn', lat: null, lng: null, address: 'Nowhere 1' })
|
||||
expect(toast.warning).toHaveBeenCalledWith('reservations.import.savedWithoutPin')
|
||||
expect(actions.addReservation).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('FE-TP-BOOKING-008: a booking that cannot be saved says so, whatever was thrown', async () => {
|
||||
const { result } = renderEdits()
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { receiptToPrefill } from '../../components/Budget/CostsPanel.helpers'
|
||||
import type { ExpensePrefill } from '../../components/Budget/CostsPanel'
|
||||
import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
|
||||
import { getImportFiles, deleteImportFiles } from '../../db/offlineDb'
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||
import { findOnMap, stayQueries } from '../../repo/mapMatchRepo'
|
||||
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
|
||||
import type { TripStoreState } from '../../store/tripStore'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
@@ -63,7 +63,10 @@ export function useBookingEdits(options: BookingEditsOptions) {
|
||||
// an existing place by name, else geocode the address and create one, then link it.
|
||||
const acc = (data as Record<string, any>).create_accommodation
|
||||
if (data.type === 'hotel' && acc && acc.venue && !acc.place_id) {
|
||||
acc.place_id = (await resolveImportedPlace(acc.venue)) ?? undefined
|
||||
const resolved = await resolveImportedPlace(acc.venue)
|
||||
acc.place_id = resolved?.id ?? undefined
|
||||
// Saved, but nowhere on the map: say so rather than leave the pin to go missing.
|
||||
if (resolved && !resolved.placed) toast.warning(t('reservations.import.savedWithoutPin'))
|
||||
delete acc.venue
|
||||
}
|
||||
// A hotel's address lives on the linked place. Write an edited address
|
||||
@@ -141,9 +144,13 @@ export function useBookingEdits(options: BookingEditsOptions) {
|
||||
}
|
||||
|
||||
// ── Review-before-save booking import ───────────────────────────────────────
|
||||
// Match an existing trip place by name, else geocode the reviewed address and
|
||||
// create one. Returns the place id (or null if even creation failed).
|
||||
const resolveImportedPlace = async (venue: { name?: string; address?: string | null }): Promise<number | null> => {
|
||||
// Match an existing trip place by name, else create one at the spot the review
|
||||
// found, or the first spot the map search knows for its name and address.
|
||||
// Returns the place and whether it has a spot on the map (null if even creation
|
||||
// failed).
|
||||
const resolveImportedPlace = async (
|
||||
venue: { name?: string; address?: string | null; lat?: number; lng?: number },
|
||||
): Promise<{ id: number; placed: boolean } | null> => {
|
||||
const name = (venue.name || '').trim()
|
||||
const n = name.toLowerCase()
|
||||
if (n) {
|
||||
@@ -153,23 +160,23 @@ export function useBookingEdits(options: BookingEditsOptions) {
|
||||
// the booking waits in the queue behind that place and is sent with its real
|
||||
// id. Online the booking goes out at once, and day_accommodations.place_id
|
||||
// carries a foreign key, so a temp id there is a 500 instead of a saved booking.
|
||||
if (existing && (existing.id > 0 || isEffectivelyOffline())) return existing.id
|
||||
if (existing && (existing.id > 0 || isEffectivelyOffline())) return { id: existing.id, placed: existing.lat != null && existing.lng != null }
|
||||
}
|
||||
let lat: number | null = null
|
||||
let lng: number | null = null
|
||||
let lat: number | null = venue.lat ?? null
|
||||
let lng: number | null = venue.lng ?? null
|
||||
let address: string | null = venue.address ?? null
|
||||
try {
|
||||
const query = venue.address ? `${name} ${venue.address}`.trim() : name
|
||||
// Offline there is no geocoder to ask; the place is created without coords.
|
||||
if (query && !isEffectivelyOffline()) {
|
||||
const res = await mapsApi.search(query)
|
||||
const hit = res?.places?.[0] as { lat?: number; lng?: number; address?: string } | undefined
|
||||
if (hit && hit.lat != null && hit.lng != null) {
|
||||
if (lat == null || lng == null) {
|
||||
try {
|
||||
// Name and address, then each alone: a booking site's romanized address
|
||||
// often matches nothing and sinks the hotel's name with it. Offline
|
||||
// findOnMap answers null and the place is created without coords.
|
||||
const hit = await findOnMap(stayQueries(name, venue.address))
|
||||
if (hit) {
|
||||
lat = hit.lat; lng = hit.lng
|
||||
if (!address && hit.address) address = hit.address
|
||||
}
|
||||
}
|
||||
} catch { /* geocode failure is non-fatal: create the place without coords */ }
|
||||
} catch { /* geocode failure is non-fatal: create the place without coords, and say so */ }
|
||||
}
|
||||
try {
|
||||
// Through the store, not placesApi directly: the API answers { place },
|
||||
// and reading .id off that wrapper linked nothing. Every save of the
|
||||
@@ -178,7 +185,7 @@ export function useBookingEdits(options: BookingEditsOptions) {
|
||||
// find it. addPlace unwraps the response and puts the place into
|
||||
// `places`, so the next save reuses it.
|
||||
const place = await tripActions.addPlace(tripId, { name: name || address || 'Accommodation', lat, lng, address })
|
||||
return place && (place.id > 0 || isEffectivelyOffline()) ? place.id : null
|
||||
return place && (place.id > 0 || isEffectivelyOffline()) ? { id: place.id, placed: lat != null && lng != null } : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,25 @@ describe('usePlannerFocusPoints', () => {
|
||||
expect(result.current.focusPoints).toEqual([[48, 11]]);
|
||||
});
|
||||
|
||||
it('FE-TP-FOCUSPOINTS-005: with nothing open the trip tours on show are framed, an open tour frames its own points', () => {
|
||||
const tours: [number, number][] = [
|
||||
[46.5, 12.0],
|
||||
[46.7, 12.2],
|
||||
];
|
||||
const neutral = renderHook(() => usePlannerFocusPoints('tour-planner', PLACES, planner(), closed, tours));
|
||||
expect(neutral.result.current.focusPoints).toEqual(tours);
|
||||
const editing = renderHook(() =>
|
||||
usePlannerFocusPoints(
|
||||
'tour-planner',
|
||||
PLACES,
|
||||
planner({ mode: { type: 'new-draft' }, waypoints: [{ lat: 1, lng: 2 }] }),
|
||||
closed,
|
||||
tours
|
||||
)
|
||||
);
|
||||
expect(editing.result.current.focusPoints).toEqual([[1, 2]]);
|
||||
});
|
||||
|
||||
it('FE-TP-FOCUSPOINTS-002: the area the plan map last showed wins once the planner opens again', () => {
|
||||
const { result, rerender } = renderHook(({ tab }) => usePlannerFocusPoints(tab, PLACES, planner(), closed), {
|
||||
initialProps: { tab: 'plan' },
|
||||
|
||||
@@ -7,14 +7,16 @@ import type { Place } from '../../types';
|
||||
/**
|
||||
* What the TOUR-PLANNER map frames: a browsed wanderer trail, the recorded
|
||||
* track or the control points of the open tour, and with nothing open the
|
||||
* area the plan map last showed (or the trip's places). The plan map reports
|
||||
* its viewport into the returned ref.
|
||||
* trip's tours on show (`overviewPoints`), else the area the plan map last
|
||||
* showed (or the trip's places). The plan map reports its viewport into the
|
||||
* returned ref.
|
||||
*/
|
||||
export function usePlannerFocusPoints(
|
||||
activeTab: string,
|
||||
places: readonly Place[],
|
||||
tourPlanner: TourPlannerController,
|
||||
wandererBrowser: Pick<WandererBrowser, 'open' | 'line'>
|
||||
wandererBrowser: Pick<WandererBrowser, 'open' | 'line'>,
|
||||
overviewPoints: readonly [number, number][] = []
|
||||
) {
|
||||
const lastPlanMapViewportRef = useRef<Bbox | null>(null);
|
||||
// Nothing reported yet when the planner first opens; the effect takes the area the plan map last showed.
|
||||
@@ -49,6 +51,7 @@ export function usePlannerFocusPoints(
|
||||
if (tourPlanner.mode.type === 'edit-saved' && tourPlanner.waypoints.length) {
|
||||
return tourPlanner.waypoints.map((point) => [point.lat, point.lng]);
|
||||
}
|
||||
if (tourPlanner.mode.type === 'neutral' && overviewPoints.length) return [...overviewPoints];
|
||||
return plannerFallbackFocusPoints;
|
||||
}, [
|
||||
wandererBrowser.open,
|
||||
@@ -56,6 +59,7 @@ export function usePlannerFocusPoints(
|
||||
tourPlanner.mode.type,
|
||||
tourPlanner.readOnlyGpxAnalysis?.routeCoordinates,
|
||||
tourPlanner.waypoints,
|
||||
overviewPoints,
|
||||
plannerFallbackFocusPoints,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// FE-TP-TOUROVERVIEW-001 to 003: the trip's tours on the Tours map while none is open.
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import type { Place } from '../../types';
|
||||
import { useTourOverview } from './useTourOverview';
|
||||
|
||||
const OPEN = {
|
||||
activeTab: 'tour-planner',
|
||||
tourPlanner: { mode: { type: 'neutral' } },
|
||||
wandererBrowser: { open: false },
|
||||
} as const;
|
||||
const EDITING = { ...OPEN, tourPlanner: { mode: { type: 'new-draft' } } } as const;
|
||||
|
||||
const PLACES = [
|
||||
{ id: 1, lat: 46.6, lng: 12.1, route_geometry: '[[46.5,12.0],[46.7,12.2],[46.6,12.3]]' },
|
||||
{ id: 2, lat: 45.4, lng: 12.3, route_geometry: '[[45.43,12.32],[45.44,12.34]]' },
|
||||
{ id: 3, lat: 45.4, lng: 12.3, route_geometry: null },
|
||||
{ id: 4, lat: 48, lng: 11, route_geometry: '[[48,11],[48.1,11.1]]' },
|
||||
] as unknown as Place[];
|
||||
const TOURS: ReadonlySet<number> = new Set([1, 2, 3]);
|
||||
|
||||
describe('useTourOverview', () => {
|
||||
beforeEach(() => resetAllStores());
|
||||
|
||||
it('FE-TP-TOUROVERVIEW-001: shows the tours with a line, not the other places', () => {
|
||||
const { result } = renderHook(() => useTourOverview(5, PLACES, TOURS, OPEN));
|
||||
expect(result.current.tourPlaces.map((place) => place.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('FE-TP-TOUROVERVIEW-002: frames each line by its corners', () => {
|
||||
const { result } = renderHook(() => useTourOverview(5, PLACES, TOURS, OPEN));
|
||||
expect(result.current.framePoints).toEqual([
|
||||
[46.5, 12.0],
|
||||
[46.7, 12.3],
|
||||
[45.43, 12.32],
|
||||
[45.44, 12.34],
|
||||
]);
|
||||
expect(result.current.focusSuffix).toBe(':tours:1,2');
|
||||
});
|
||||
|
||||
it('FE-TP-TOUROVERVIEW-003: shows nothing while a tour is open', () => {
|
||||
const { result } = renderHook(() => useTourOverview(5, PLACES, TOURS, EDITING));
|
||||
expect(result.current).toEqual({ tourPlaces: [], framePoints: [], focusSuffix: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner';
|
||||
import { useTourMapVisibility } from '../../components/Tours/useTourMapVisibility';
|
||||
import type { Place } from '../../types';
|
||||
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry';
|
||||
|
||||
/**
|
||||
* The trip's tours on the Tours map while none is open, as the plan map shows them:
|
||||
* each tour's place, whose line and marker the map draws in the tour's colour, and the
|
||||
* corners of those lines for the camera to frame. A tour hidden from the map in the
|
||||
* list stays off it here too. Empty off the Tours tab and while a tour is open, an
|
||||
* import viewed or the wanderer browser up, so the map then shows that alone.
|
||||
*/
|
||||
export function useTourOverview(
|
||||
tripId: number,
|
||||
places: readonly Place[],
|
||||
tourPlaceIds: ReadonlySet<number>,
|
||||
view: {
|
||||
activeTab: string;
|
||||
tourPlanner: Pick<TourPlannerController, 'mode'>;
|
||||
wandererBrowser: { open: boolean };
|
||||
}
|
||||
) {
|
||||
const showing =
|
||||
view.activeTab === 'tour-planner' && view.tourPlanner.mode.type === 'neutral' && !view.wandererBrowser.open;
|
||||
const { hiddenIds } = useTourMapVisibility(tripId, tourPlaceIds);
|
||||
const tourPlaces = useMemo(
|
||||
() =>
|
||||
showing
|
||||
? places.filter((place) => tourPlaceIds.has(place.id) && !hiddenIds.has(place.id) && !!place.route_geometry)
|
||||
: [],
|
||||
[showing, places, tourPlaceIds, hiddenIds]
|
||||
);
|
||||
const framePoints = useMemo(() => tourPlaces.flatMap((place) => cornersOf(place.route_geometry)), [tourPlaces]);
|
||||
return {
|
||||
tourPlaces,
|
||||
framePoints,
|
||||
// Part of the camera's key, so the map frames the tours again once they are loaded or one is shown or hidden.
|
||||
focusSuffix: tourPlaces.length ? `:tours:${tourPlaces.map((place) => place.id).join(',')}` : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** The south-west and north-east corners of a stored line, or nothing for a line that does not parse. */
|
||||
function cornersOf(routeGeometry: string | null | undefined): [number, number][] {
|
||||
const points = parseRenderableRouteGeometry(routeGeometry);
|
||||
if (!points?.length) return [];
|
||||
const lats = points.map(([lat]) => lat);
|
||||
const lngs = points.map(([, lng]) => lng);
|
||||
return [
|
||||
[Math.min(...lats), Math.min(...lngs)],
|
||||
[Math.max(...lats), Math.max(...lngs)],
|
||||
];
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useMapLocationSearch } from './useMapLocationSearch'
|
||||
import { usePlannerFocusPoints } from './usePlannerFocusPoints'
|
||||
import { useTripPlanner } from './useTripPlanner'
|
||||
import { useTourWorkspace } from './useTourWorkspace'
|
||||
import { useTourOverview } from './useTourOverview'
|
||||
import { mapFocusKeyOf, offeredWaysOf, paddleLineOf } from './tourMapFocus'
|
||||
import { placeFormContext } from './placeFormContext'
|
||||
|
||||
@@ -41,7 +42,8 @@ export function useTripPlannerPage() {
|
||||
useAssistantEntityLinks({ planner, workspace })
|
||||
|
||||
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
|
||||
const { focusPoints, lastPlanMapViewportRef } = usePlannerFocusPoints(activeTab, places, tourPlanner, wandererBrowser)
|
||||
const tourOverview = useTourOverview(tripId, places, planner.tourPlaceIds, { activeTab, tourPlanner, wandererBrowser })
|
||||
const { focusPoints, lastPlanMapViewportRef } = usePlannerFocusPoints(activeTab, places, tourPlanner, wandererBrowser, tourOverview.framePoints)
|
||||
|
||||
// PAD-06: an obstacle or area the paddle checklist asks for moves the camera as a picked search result does.
|
||||
const paddleFocus = usePaddleObstacleStore(state => state.focus)
|
||||
@@ -152,7 +154,9 @@ export function useTripPlannerPage() {
|
||||
variantLines: drawn.overlays,
|
||||
previewPoints: tourConversion.status === 'idle' ? null : tourConversion.points,
|
||||
focusPoints: offered.frame?.points ?? mapFocusPoints,
|
||||
focusKey: mapFocusKeyOf(wandererBrowser, offered.frame, mapFocusKey),
|
||||
focusKey: mapFocusKeyOf(wandererBrowser, offered.frame, mapFocusKey) + tourOverview.focusSuffix,
|
||||
overviewPlaces: tourOverview.tourPlaces,
|
||||
openOverviewTour: workspace.openTourInPlanner,
|
||||
captureViewport,
|
||||
},
|
||||
locationSearch,
|
||||
|
||||
@@ -104,6 +104,11 @@ export const aiConnectionsRepo = {
|
||||
return aiAdminConnectionResponseSchema.parse(await aiAdminApi.saveConnection(body));
|
||||
},
|
||||
|
||||
async setAdminModel(model: string) {
|
||||
requireOnline();
|
||||
return aiAdminConnectionResponseSchema.parse(await aiAdminApi.setModel(model));
|
||||
},
|
||||
|
||||
async removeAdminConnection(): Promise<void> {
|
||||
requireOnline();
|
||||
await aiAdminApi.removeConnection();
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { mapsApi } from '../api/client';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
import { findOnMap, stayQueries } from './mapMatchRepo';
|
||||
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
|
||||
vi.mock('../api/client', () => ({ mapsApi: { search: vi.fn() } }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
|
||||
vi.mocked(mapsApi.search).mockResolvedValue({ places: [] } as never);
|
||||
});
|
||||
|
||||
describe('finding a stay on the map', () => {
|
||||
it('FE-MAP-MATCH-001: asks name and address together, then the name, then the address, each once', () => {
|
||||
expect(stayQueries('Hotel Gracery', '1-19-1 Kabukicho, Shinjuku')).toEqual([
|
||||
'Hotel Gracery 1-19-1 Kabukicho, Shinjuku',
|
||||
'Hotel Gracery',
|
||||
'1-19-1 Kabukicho, Shinjuku',
|
||||
]);
|
||||
expect(stayQueries(' Hut ', null)).toEqual(['Hut']);
|
||||
expect(stayQueries('', 'Main 1')).toEqual(['Main 1']);
|
||||
expect(stayQueries('Inn', 'Inn')).toEqual(['Inn Inn', 'Inn']);
|
||||
expect(stayQueries('', ' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MAP-MATCH-002: a romanized address that finds nothing falls back to the hotel name', async () => {
|
||||
vi.mocked(mapsApi.search).mockImplementation(async (query: string) =>
|
||||
query === 'Hotel Gracery'
|
||||
? ({
|
||||
places: [{ name: 'Hotel Gracery Shinjuku', address: 'Kabukicho, Tokyo', lat: 35.69, lng: 139.7 }],
|
||||
} as never)
|
||||
: ({ places: [] } as never)
|
||||
);
|
||||
await expect(findOnMap(stayQueries('Hotel Gracery', '1-19-1 Kabukicho'), 'en')).resolves.toEqual({
|
||||
lat: 35.69,
|
||||
lng: 139.7,
|
||||
label: 'Hotel Gracery Shinjuku, Kabukicho, Tokyo',
|
||||
address: 'Kabukicho, Tokyo',
|
||||
});
|
||||
expect(vi.mocked(mapsApi.search).mock.calls.map(([q]) => q)).toEqual([
|
||||
'Hotel Gracery 1-19-1 Kabukicho',
|
||||
'Hotel Gracery',
|
||||
]);
|
||||
expect(mapsApi.search).toHaveBeenLastCalledWith('Hotel Gracery', 'en');
|
||||
});
|
||||
|
||||
it('FE-MAP-MATCH-003: a row without coordinates is no match, and without a language the search is asked as before', async () => {
|
||||
vi.mocked(mapsApi.search).mockResolvedValueOnce({
|
||||
places: [
|
||||
{ name: 'Nowhere', lat: null, lng: 2 },
|
||||
{ name: 'Inn', lat: 1, lng: 2 },
|
||||
],
|
||||
} as never);
|
||||
await expect(findOnMap(['Inn'])).resolves.toEqual({ lat: 1, lng: 2, label: 'Inn' });
|
||||
expect(mapsApi.search).toHaveBeenCalledWith('Inn');
|
||||
|
||||
vi.mocked(mapsApi.search).mockResolvedValueOnce(undefined as never);
|
||||
await expect(findOnMap(['Gone'])).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MAP-MATCH-004: offline there is nothing to ask', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
|
||||
await expect(findOnMap(['Hotel'])).resolves.toBeNull();
|
||||
expect(mapsApi.search).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mapsApi } from '../api/client';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
|
||||
/** A spot the map search found for a place that so far is only a name and an address. */
|
||||
export interface MapMatch {
|
||||
lat: number;
|
||||
lng: number;
|
||||
/** What the search found, as the reader sees it: its name, and its address when it has one. */
|
||||
label: string;
|
||||
/** The found place's own address, for a stay whose booking named none. */
|
||||
address?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways to ask for a stay, best first: name and address together, then the
|
||||
* name alone, then the address alone. A booking site's romanized address (an
|
||||
* Agoda stay in Japan, say) often matches nothing, and asked together with the
|
||||
* hotel's name it sinks the name too; the name alone usually finds the hotel.
|
||||
*/
|
||||
export function stayQueries(name: string, address: string | null | undefined): string[] {
|
||||
const n = name.trim();
|
||||
const a = (address ?? '').trim();
|
||||
const queries = [n && a ? `${n} ${a}` : '', n, a].filter((q) => q.length >= 3);
|
||||
return [...new Set(queries)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The first spot the map search knows for one of `queries`, asked in order.
|
||||
* Online only, on purpose: offline there is no search to ask, and the answer
|
||||
* is null rather than a guess.
|
||||
*/
|
||||
export async function findOnMap(queries: string[], lang?: string): Promise<MapMatch | null> {
|
||||
if (isEffectivelyOffline()) return null;
|
||||
for (const query of queries) {
|
||||
const res = lang ? await mapsApi.search(query, lang) : await mapsApi.search(query);
|
||||
for (const row of res?.places ?? []) {
|
||||
const place = row as { lat?: unknown; lng?: unknown; name?: unknown; address?: unknown };
|
||||
const lat = typeof place.lat === 'number' ? place.lat : NaN;
|
||||
const lng = typeof place.lng === 'number' ? place.lng : NaN;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||
const parts = [place.name, place.address].filter((v): v is string => typeof v === 'string' && v.trim() !== '');
|
||||
const address = typeof place.address === 'string' && place.address.trim() ? place.address : undefined;
|
||||
return { lat, lng, label: parts.join(', ') || query, ...(address ? { address } : {}) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { offlineDb } from '../db/offlineDb';
|
||||
import { routeCacheRepo } from './routeCacheRepo';
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
await offlineDb.routeCache.clear();
|
||||
});
|
||||
|
||||
const ROUTE = { coordinates: [[48.1, 11.5]], distance: 4200, duration: 600, legs: [] };
|
||||
|
||||
describe('road routes kept on the device', () => {
|
||||
it('FE-ROUTE-CACHE-001: a kept route comes back as it went in, a missing one as null', async () => {
|
||||
await routeCacheRepo.put('driving:km:11.5,48.1;11.6,48.2', ROUTE);
|
||||
await expect(routeCacheRepo.get('driving:km:11.5,48.1;11.6,48.2')).resolves.toEqual(ROUTE);
|
||||
await expect(routeCacheRepo.get('walking:km:11.5,48.1;11.6,48.2')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ROUTE-CACHE-002: a route older than a month is asked again', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-09-01T00:00:00Z'));
|
||||
await routeCacheRepo.put('driving:km:old', ROUTE);
|
||||
vi.setSystemTime(new Date('2026-10-25T00:00:00Z'));
|
||||
await expect(routeCacheRepo.get('driving:km:old')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ROUTE-CACHE-003: a database that fails is a miss on read and nothing on write', async () => {
|
||||
vi.spyOn(offlineDb.routeCache, 'get').mockRejectedValueOnce(new Error('closed'));
|
||||
await expect(routeCacheRepo.get('driving:km:any')).resolves.toBeNull();
|
||||
vi.spyOn(offlineDb.routeCache, 'put').mockRejectedValueOnce(new Error('quota'));
|
||||
await expect(routeCacheRepo.put('driving:km:any', ROUTE)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { offlineDb } from '../db/offlineDb';
|
||||
|
||||
/** Roads change; an answer older than this is asked again. */
|
||||
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
/** How many routes the device keeps before the oldest give way. */
|
||||
const MAX_ROWS = 3000;
|
||||
/** Pruning counts the table, so it runs every so many writes rather than on each one. */
|
||||
const PRUNE_EVERY = 100;
|
||||
|
||||
let writes = 0;
|
||||
|
||||
async function prune(): Promise<void> {
|
||||
const extra = (await offlineDb.routeCache.count()) - MAX_ROWS;
|
||||
if (extra <= 0) return;
|
||||
const oldest = await offlineDb.routeCache.orderBy('savedAt').limit(extra).primaryKeys();
|
||||
await offlineDb.routeCache.bulkDelete(oldest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Road routes kept on the device, by RouteCalculator's cache key, so a trip that
|
||||
* shows its whole route opens with its roads instead of asking the router for
|
||||
* every leg again, one a second, on every visit (#2794). A convenience only:
|
||||
* anything this store cannot read or write is simply routed again.
|
||||
*/
|
||||
export const routeCacheRepo = {
|
||||
/** The route kept under `key`, unless it is missing or too old. */
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const row = await offlineDb.routeCache.get(key);
|
||||
if (!row || Date.now() - row.savedAt > MAX_AGE_MS) return null;
|
||||
return row.route as T;
|
||||
} catch {
|
||||
// A closed or blocked database: the leg is routed again.
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Keeps a route the router just answered. */
|
||||
async put(key: string, route: unknown): Promise<void> {
|
||||
try {
|
||||
await offlineDb.routeCache.put({ key, route, savedAt: Date.now() });
|
||||
writes += 1;
|
||||
if (writes % PRUNE_EVERY === 0) await prune();
|
||||
} catch {
|
||||
// A full or blocked database: the route still serves this visit from memory.
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-STORE-001 to FE-AI-STORE-012
|
||||
* FE-AI-STORE-001 to FE-AI-STORE-014
|
||||
*
|
||||
* The store runs a turn end to end against a faked repo: the request it
|
||||
* sends, how the stream lands in the chat, and what each way of failing
|
||||
@@ -9,11 +9,14 @@
|
||||
*/
|
||||
import type { AiStatusResponse, AssistantMessage, AssistantThreadResponse, UiStreamChunk } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { seedStore } from '../../tests/helpers/store';
|
||||
import { AssistantRequestError } from '../api/assistant';
|
||||
import { aiConnectionsRepo } from '../repo/aiConnectionsRepo';
|
||||
import { assistantRepo } from '../repo/assistantRepo';
|
||||
import { OnlineOnlyError } from '../repo/onlineOnly';
|
||||
import { useAddonStore } from './addonStore';
|
||||
import { chatOf, useAssistantStore } from './assistantStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
|
||||
const TRIP = 4;
|
||||
@@ -60,6 +63,10 @@ async function* hangingStream(first: UiStreamChunk[], signal: AbortSignal): Asyn
|
||||
});
|
||||
}
|
||||
|
||||
/** The addon list as it lands from the server, with these addons on. */
|
||||
const addonsOn = (...ids: string[]) =>
|
||||
seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) });
|
||||
|
||||
const chat = () => chatOf(useAssistantStore.getState(), TRIP);
|
||||
const store = () => useAssistantStore.getState();
|
||||
|
||||
@@ -76,6 +83,7 @@ beforeEach(() => {
|
||||
lastApplied: null,
|
||||
});
|
||||
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: 'de' } }));
|
||||
useAuthStore.setState({ managed: false });
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -311,6 +319,7 @@ describe('assistantStore: threads, window and status', () => {
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-012: status fails closed, offline keeps the last answer, and the privacy notice is acknowledged', async () => {
|
||||
addonsOn('ai_assistant');
|
||||
vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValueOnce(STATUS);
|
||||
await store().loadStatus();
|
||||
expect(store().status).toEqual(STATUS);
|
||||
@@ -328,4 +337,39 @@ describe('assistantStore: threads, window and status', () => {
|
||||
await store().loadStatus();
|
||||
expect(store().status).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-013: the status is asked for only while the assistant or AI parsing is on', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
useAddonStore.setState({ addons: [], loaded: false });
|
||||
await store().loadStatus();
|
||||
addonsOn('mcp');
|
||||
await store().loadStatus();
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(store().status).toBeNull();
|
||||
|
||||
addonsOn('llm_parsing');
|
||||
await store().loadStatus();
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
expect(store().status).toEqual(STATUS);
|
||||
|
||||
addonsOn('ai_assistant');
|
||||
await store().loadStatus();
|
||||
expect(status).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Switched off again, the assistant goes away without a request.
|
||||
addonsOn();
|
||||
await store().loadStatus();
|
||||
expect(status).toHaveBeenCalledTimes(2);
|
||||
expect(store().status).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-014: a managed install, which refuses /api/ai, is not asked whatever its addons say', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
useAssistantStore.setState({ status: STATUS });
|
||||
useAuthStore.setState({ managed: true });
|
||||
addonsOn('ai_assistant', 'llm_parsing');
|
||||
await store().loadStatus();
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(store().status).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { Assignment } from '../types';
|
||||
import { browserTimeZone } from '../utils/localDate';
|
||||
import { randomId } from '../utils/randomId';
|
||||
import type { RouteProfileFocus } from '../utils/routeGeometry';
|
||||
import { useAddonStore } from './addonStore';
|
||||
import { assistantScopeOf, assistantTargetOf, type AssistantChatKey } from './assistantChatKey';
|
||||
import {
|
||||
blockDays,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
type WindowRect,
|
||||
} from './assistantMapModel';
|
||||
import { applyChunk, closeUndo, markDecisions } from './assistantMessages';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
import { useTripStore } from './tripStore';
|
||||
|
||||
@@ -120,7 +122,10 @@ interface UndoBlockResult {
|
||||
}
|
||||
|
||||
interface AssistantState {
|
||||
/** What the assistant runs on for this user; null until loaded, and the assistant stays hidden until then. */
|
||||
/**
|
||||
* What the assistant runs on for this user; null until loaded, while no addon
|
||||
* opens /api/ai (aiRoutesOpen) and on a managed install, and the assistant stays hidden then.
|
||||
*/
|
||||
status: AiStatusResponse | null;
|
||||
window: { open: boolean; minimized: boolean };
|
||||
/** The chats by `AssistantChatKey`: a trip's id, or a journey's key. */
|
||||
@@ -316,6 +321,19 @@ export function chatOf(state: Pick<AssistantState, 'byTrip'>, key: AssistantChat
|
||||
return state.byTrip[key] ?? EMPTY_CHAT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the addons open /api/ai: the server opens it while the assistant or
|
||||
* AI parsing is on, the two sharing one connection, and answers 404 while
|
||||
* both are off. Closed until the addon list is in. A selector on the addon
|
||||
* store; a managed install refuses /api/ai whatever it says (loadStatus).
|
||||
*/
|
||||
export function aiRoutesOpen({
|
||||
loaded,
|
||||
isEnabled,
|
||||
}: Pick<ReturnType<typeof useAddonStore.getState>, 'loaded' | 'isEnabled'>): boolean {
|
||||
return loaded && (isEnabled('ai_assistant') || isEnabled('llm_parsing'));
|
||||
}
|
||||
|
||||
function httpStatus(err: unknown): number | undefined {
|
||||
return (err as { response?: { status?: number } } | null)?.response?.status;
|
||||
}
|
||||
@@ -568,6 +586,12 @@ function backToAfter(byTrip: Record<number, AssistantTripChat>): Record<number,
|
||||
function shellActions(ctx: Ctx) {
|
||||
return {
|
||||
loadStatus: async () => {
|
||||
// While /api/ai is closed the request could only get a 404, or the 403 of a managed install: nothing is asked,
|
||||
// and the assistant stays hidden. The managed flag comes with the app config, which can land after the addons.
|
||||
if (useAuthStore.getState().managed || !aiRoutesOpen(useAddonStore.getState())) {
|
||||
ctx.set({ status: null });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ctx.set({ status: await aiConnectionsRepo.status() });
|
||||
} catch (err) {
|
||||
|
||||
@@ -211,16 +211,17 @@
|
||||
.trek-dash .st-sep { width: 1px; height: 20px; background: var(--line-2); margin: 0 2px; flex: none; }
|
||||
|
||||
/* ----------------- body: rail | workbench | inspector ----------------- */
|
||||
/* The rail is 64px, or as wide as the longest word in a section's name. */
|
||||
.trek-dash .st-body {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 236px minmax(0, 1fr) 300px;
|
||||
grid-template-columns: minmax(64px, min-content) 236px minmax(0, 1fr) 300px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Under 1280 the inspector floats over the workbench instead of narrowing it —
|
||||
below roughly 700px of canvas a spread stops being legible at any zoom. */
|
||||
@media (max-width: 1279px) {
|
||||
.trek-dash .st-body { grid-template-columns: 64px 236px minmax(0, 1fr); }
|
||||
.trek-dash .st-body { grid-template-columns: minmax(64px, min-content) 236px minmax(0, 1fr); }
|
||||
.trek-dash .st-inspector {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
@@ -231,12 +232,11 @@
|
||||
}
|
||||
}
|
||||
@media (max-width: 1079px) {
|
||||
.trek-dash .st-body { grid-template-columns: 64px minmax(0, 1fr); }
|
||||
.trek-dash .st-body { grid-template-columns: minmax(64px, min-content) minmax(0, 1fr); }
|
||||
/* The panel floats over the workbench's cell, so it starts wherever the rail ends. */
|
||||
.trek-dash .st-side, .trek-dash .st-work-wrap { grid-area: 1 / 2; }
|
||||
.trek-dash .st-side {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
inset-inline-start: 64px;
|
||||
bottom: 0;
|
||||
justify-self: start;
|
||||
width: 236px;
|
||||
box-shadow: var(--sh-lg);
|
||||
}
|
||||
@@ -457,7 +457,6 @@
|
||||
}
|
||||
.trek-dash .st-rail-btn {
|
||||
flex: none;
|
||||
width: 52px;
|
||||
min-height: 52px;
|
||||
padding: 6px 2px;
|
||||
display: flex;
|
||||
@@ -469,9 +468,9 @@
|
||||
color: var(--ink-3);
|
||||
transition: background .16s, color .16s;
|
||||
}
|
||||
/* Two lines at most, broken inside a word that is longer than the rail is wide
|
||||
("Путешествие", "Tərtibatlar"); the button's title carries the whole name. */
|
||||
.trek-dash .st-rail-btn span {
|
||||
/* Wrapped between words, never inside one: the rail grows to the longest word
|
||||
("Maquetacions", "Путешествие"), and keep-all holds "コンテンツ" whole too. */
|
||||
.trek-dash .st-rail-btn .st-rail-label {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -481,8 +480,9 @@
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
overflow-wrap: normal;
|
||||
word-break: keep-all;
|
||||
hyphens: manual;
|
||||
}
|
||||
.trek-dash .st-rail-btn:focus { outline: none; }
|
||||
.trek-dash .st-rail-btn:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 3px var(--accent); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
alongShortestArc,
|
||||
computeMapViewport,
|
||||
MAX_ZOOM_GL,
|
||||
MAX_ZOOM_RASTER,
|
||||
@@ -263,3 +264,26 @@ describe('computeMapViewport', () => {
|
||||
expect(withPadding.zoom).toBe(equivalent.zoom)
|
||||
})
|
||||
})
|
||||
|
||||
describe('alongShortestArc', () => {
|
||||
const FIJI: [number, number] = [-18.14, 178.44]
|
||||
const TONGA: [number, number] = [-21.14, -175.2]
|
||||
const SAMOA: [number, number] = [-13.83, -171.76]
|
||||
|
||||
it('carries the places past the antimeridian on beyond 180, so a min-max box between them is the short one', () => {
|
||||
const lngs = alongShortestArc([FIJI, TONGA, SAMOA]).map(([, lng]) => Math.round(lng * 100) / 100)
|
||||
|
||||
// From Fiji east across the date line: under ten degrees wide, not the 350 the other way round.
|
||||
expect(lngs).toEqual([178.44, 184.8, 188.24])
|
||||
})
|
||||
|
||||
it('leaves places that do not cross the antimeridian as they are', () => {
|
||||
const cafes: [number, number][] = [[48.21, 16.37], [38.71, -9.14], [55.7, 12.55]]
|
||||
|
||||
expect(alongShortestArc(cafes)).toEqual(cafes)
|
||||
})
|
||||
|
||||
it('has nothing to lay out for no places', () => {
|
||||
expect(alongShortestArc([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -146,6 +146,18 @@ function lngExtent(lngs: number[], wrapsWorld: boolean): { west: number; east: n
|
||||
return { west, east, span: east - west }
|
||||
}
|
||||
|
||||
/**
|
||||
* `[lat, lng]` points with their longitudes laid along the narrowest arc that holds them all
|
||||
* (lngExtent), for a renderer that wraps the world: a point past the antimeridian goes on
|
||||
* beyond 180 instead of starting over at -180. A bounding box built from them by plain min and max (MapLibre's and Mapbox's
|
||||
* extend) then spans the few degrees from Fiji to Samoa, not the rest of the world.
|
||||
*/
|
||||
export function alongShortestArc(points: readonly [number, number][]): [number, number][] {
|
||||
if (points.length === 0) return []
|
||||
const { west } = lngExtent(points.map(([, lng]) => lng), true)
|
||||
return points.map(([lat, lng]) => [lat, lng < west ? lng + 360 : lng])
|
||||
}
|
||||
|
||||
/** Zoom at which a span covering `fraction` of the world fills `px` pixels. */
|
||||
function zoomForFraction(px: number, tileSize: number, fraction: number, maxZoom: number): number {
|
||||
// A zero fraction (identical coordinates) would divide by zero and yield Infinity.
|
||||
|
||||
@@ -27,6 +27,15 @@ describe('analyzeRouteGeometry', () => {
|
||||
expect(analysis!.distanceIndexedProfileSamples[2].distanceMeters).toBeCloseTo(3335.85, 1)
|
||||
})
|
||||
|
||||
it('leaves the elevation model jitter of a flat walk out of its ascent', () => {
|
||||
const analysis = analyzeRouteGeometry(JSON.stringify([
|
||||
[45.44, 12.32, 2], [45.441, 12.32, 9], [45.442, 12.32, 1], [45.443, 12.32, 4], [45.444, 12.32, 3],
|
||||
]))
|
||||
expect(analysis?.maxEle).toBe(9)
|
||||
expect(analysis?.gain).toBe(7)
|
||||
expect(analysis?.loss).toBe(8)
|
||||
})
|
||||
|
||||
it('maps route endpoints and interpolates focus between prepared samples', () => {
|
||||
const samples = [
|
||||
{ distanceMeters: 0, elevationMeters: 100, lat: 10, lng: 20 },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { elevationGainLoss } from '@trek/shared'
|
||||
|
||||
export interface DistanceIndexedProfileSample {
|
||||
distanceMeters: number
|
||||
elevationMeters: number
|
||||
@@ -109,8 +111,6 @@ export function analyzeRouteGeometry(routeGeometry: string | null | undefined):
|
||||
let distanceMeters = 0
|
||||
let minEle = hasElevation ? points[0][2]! : null
|
||||
let maxEle = minEle
|
||||
let gain = hasElevation ? 0 : null
|
||||
let loss = hasElevation ? 0 : null
|
||||
const distanceIndexedProfileSamples: DistanceIndexedProfileSample[] = hasElevation
|
||||
? [{ distanceMeters: 0, elevationMeters: points[0][2]!, lat: points[0][0], lng: points[0][1] }]
|
||||
: []
|
||||
@@ -122,19 +122,18 @@ export function analyzeRouteGeometry(routeGeometry: string | null | undefined):
|
||||
const elevation = points[index][2]!
|
||||
minEle = Math.min(minEle!, elevation)
|
||||
maxEle = Math.max(maxEle!, elevation)
|
||||
const delta = elevation - points[index - 1][2]!
|
||||
if (delta > 0) gain! += delta
|
||||
else loss! += Math.abs(delta)
|
||||
distanceIndexedProfileSamples.push({ distanceMeters, elevationMeters: elevation, lat: points[index][0], lng: points[index][1] })
|
||||
}
|
||||
|
||||
// Ascent and descent with the elevation model's jitter left out, as the server counts them.
|
||||
const climb = hasElevation ? elevationGainLoss(points.map(point => point[2]!)) : null
|
||||
return {
|
||||
routeCoordinates: points.map(([lat, lng]) => [lat, lng]),
|
||||
distanceKm: distanceMeters / 1000,
|
||||
minEle,
|
||||
maxEle,
|
||||
gain,
|
||||
loss,
|
||||
gain: climb ? climb.gain : null,
|
||||
loss: climb ? climb.loss : null,
|
||||
distanceIndexedProfileSamples,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const HISTORY: Record<number, StoreSpec> = {
|
||||
11: { ferrataTiles: '[tripId+tile], tripId, fetchedAt' },
|
||||
12: { tourStationVisits: '[assignment_id+station_key], trip_id, assignment_id' },
|
||||
13: { tourDrafts: 'tripId' },
|
||||
14: { routeCache: 'key, savedAt' },
|
||||
};
|
||||
|
||||
const HISTORY_VERSIONS = Object.keys(HISTORY)
|
||||
@@ -136,6 +137,7 @@ function rowsAt(version: number): Record<string, Record<string, unknown>> {
|
||||
ferrataTiles: { tripId: 1, tile: '930:236', fetchedAt: 11, ferratas: [] },
|
||||
tourStationVisits: { assignment_id: 71, station_key: 'stop-01', status: 'visited', trip_id: 1 },
|
||||
tourDrafts: { tripId: 1, draft: { payload: '{"version":11}', updated_at: '2026-10-22 09:00:00' } },
|
||||
routeCache: { key: 'driving:km:11.5,48.1;11.6,48.2', route: { coordinates: [], legs: [] }, savedAt: 13 },
|
||||
};
|
||||
const tables = schemaAt(version);
|
||||
return Object.fromEntries(Object.entries(all).filter(([table]) => table in tables));
|
||||
@@ -344,6 +346,24 @@ const UPGRADE_CASES: Record<number, UpgradeCase> = {
|
||||
expect((await db.tourDrafts.get(3))?.draft.payload).toBe('{"name":"C"}');
|
||||
},
|
||||
},
|
||||
14: {
|
||||
intent: 'adds the road routes kept on the device and keeps the account drafts of the Tours planner',
|
||||
check: async (db) => {
|
||||
expect((await db.tourDrafts.get(1))?.draft.payload).toBe('{"version":11}');
|
||||
await db.table('routeCache').bulkPut([
|
||||
{ key: 'driving:km:1,2;3,4', route: { legs: [] }, savedAt: 20 },
|
||||
{ key: 'walking:km:1,2;3,4', route: { legs: [] }, savedAt: 10 },
|
||||
]);
|
||||
// One row per key: the same route answered again replaces the old one.
|
||||
await db.table('routeCache').put({ key: 'driving:km:1,2;3,4', route: { legs: [1] }, savedAt: 30 });
|
||||
expect(await db.routeCache.count()).toBe(2);
|
||||
// The oldest come first, which is how the store prunes.
|
||||
expect(await db.routeCache.orderBy('savedAt').primaryKeys()).toEqual([
|
||||
'walking:km:1,2;3,4',
|
||||
'driving:km:1,2;3,4',
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('offlineDb upgrades', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import MAssistantHomeScreen from '../../../src/mobile/screens/assistant/MAssista
|
||||
import { chatOf, useAssistantStore } from '../../../src/store/assistantStore';
|
||||
import { act, cleanup, fireEvent, render, screen } from '../../helpers/render';
|
||||
|
||||
// FE-MOB-ASSIST-HOME-001 to FE-MOB-ASSIST-HOME-005
|
||||
// FE-MOB-ASSIST-HOME-001 to FE-MOB-ASSIST-HOME-006
|
||||
//
|
||||
// The phone's home chat, full screen outside a trip, around the shared chat
|
||||
// surface, which is replaced here: what is tested is when it shows, its
|
||||
@@ -141,4 +141,9 @@ describe('MAssistantHomeScreen', () => {
|
||||
renderAt('/settings');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASSIST-HOME-006: brings its own m-root, so the phone tokens resolve outside the mobile shell', () => {
|
||||
renderAt('/dashboard');
|
||||
expect(screen.getByRole('dialog', { name: 'Assistant' }).closest('.m-root')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,10 @@ services:
|
||||
- CHOWN
|
||||
- SETUID
|
||||
- SETGID
|
||||
# dumb-init runs as root and forwards SIGTERM to node, which runs as the
|
||||
# node user; signalling another user's process needs KILL. Without it
|
||||
# docker stop ends in SIGKILL and the graceful shutdown never runs.
|
||||
- KILL
|
||||
tmpfs:
|
||||
- /tmp:noexec,nosuid,size=128m
|
||||
ports:
|
||||
|
||||
Generated
+51
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@trek/root",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/abril-fatface": "^5.3.0",
|
||||
@@ -9370,7 +9370,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -9384,7 +9385,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -9398,7 +9400,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.62.0",
|
||||
@@ -9412,7 +9415,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -9426,7 +9430,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.62.0",
|
||||
@@ -9440,7 +9445,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.62.0",
|
||||
@@ -9454,7 +9460,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.62.0",
|
||||
@@ -9468,7 +9475,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -9482,7 +9490,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -9509,7 +9518,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -9523,7 +9533,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -9537,7 +9548,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -9551,7 +9563,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -9565,7 +9578,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -9579,7 +9593,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -9593,7 +9608,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -9607,7 +9623,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -9634,7 +9651,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -9648,7 +9666,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -9662,7 +9681,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -9676,7 +9696,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -9690,7 +9711,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -9704,7 +9726,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
@@ -24982,7 +25005,7 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@trek/server",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"dependencies": {
|
||||
"@aws-lite/client": "^0.23.7",
|
||||
"@aws-lite/s3": "^0.2.7",
|
||||
@@ -25361,7 +25384,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@trek/shared",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^3.23.0",
|
||||
"zod": "^4.6.7"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user