Compare commits

..

2 Commits

Author SHA1 Message Date
Maurice 02a12ce918 test(studio): a Studio save carries its revision, and the layers foot is drawn with its selection 2026-10-23 20:11:03 +02:00
Maurice 2f4644d434 feat(studio): groups, a layers panel, marquee, nudge, multi-resize, align and paste style
Elements can be grouped on a page and picked, moved, resized, aligned and
restacked as one. A layers panel lists the page front to back with lock,
visibility, rename and drag to restack. The sheet gains marquee selection,
arrow-key nudge, one box that resizes a multi-selection with Shift holding
the proportions, and the design-tool shortcuts. The inspector lines a
selection up and spaces it out, and copies and pastes a style.

The assistant gets the same through the group, ungroup, name and hide
operations, and describe_book_page lists the groups. The format gains the
optional group, name and hidden fields and a spread's group names, so
BOOK_SCHEMA_REV goes to 2.
2026-10-23 19:31:14 +02:00
1304 changed files with 7494 additions and 94900 deletions
-51
View File
@@ -53,54 +53,3 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
`server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain.
## Typefaces: Studio books and the app
Every typeface a book can be set in, and the app's own (Poppins, MuseoModerno,
Geist), is self-hosted from the **@fontsource** npm packages, which redistribute
the families published on Google Fonts split into subsets by script. The catalogue of book faces is
`shared/src/book/book-fonts.data.ts`. Each package carries its licence and the
copyright notice of the family's authors in its `LICENSE` file.
Both licences allow a font to be bundled with software and embedded in a
document made with it, so a printed or exported book may carry the faces it is
set in. No family is renamed, redrawn or sold on its own. SIL OFL 1.1: https://openfontlicense.org. Apache 2.0:
https://www.apache.org/licenses/LICENSE-2.0
| Family | Package | Licence |
|---|---|---|
| Abril Fatface | `@fontsource/abril-fatface` | SIL Open Font License 1.1 |
| Alfa Slab One | `@fontsource/alfa-slab-one` | SIL Open Font License 1.1 |
| Allura | `@fontsource/allura` | SIL Open Font License 1.1 |
| Anton | `@fontsource/anton` | SIL Open Font License 1.1 |
| Archivo Black | `@fontsource/archivo-black` | SIL Open Font License 1.1 |
| Bebas Neue | `@fontsource/bebas-neue` | SIL Open Font License 1.1 |
| Big Shoulders Display | `@fontsource/big-shoulders-display` | SIL Open Font License 1.1 |
| Bodoni Moda | `@fontsource/bodoni-moda` | SIL Open Font License 1.1 |
| Bricolage Grotesque | `@fontsource/bricolage-grotesque` | SIL Open Font License 1.1 |
| Caveat | `@fontsource/caveat` | SIL Open Font License 1.1 |
| Cormorant Garamond | `@fontsource/cormorant-garamond` | SIL Open Font License 1.1 |
| Dancing Script | `@fontsource/dancing-script` | SIL Open Font License 1.1 |
| DM Mono | `@fontsource/dm-mono` | SIL Open Font License 1.1 |
| DM Serif Display | `@fontsource/dm-serif-display` | SIL Open Font License 1.1 |
| EB Garamond | `@fontsource/eb-garamond` | SIL Open Font License 1.1 |
| Fraunces | `@fontsource/fraunces` | SIL Open Font License 1.1 |
| Great Vibes | `@fontsource/great-vibes` | SIL Open Font License 1.1 |
| Homemade Apple | `@fontsource/homemade-apple` | Apache License 2.0 |
| Inter | `@fontsource/inter` | SIL Open Font License 1.1 |
| Kaushan Script | `@fontsource/kaushan-script` | SIL Open Font License 1.1 |
| League Gothic | `@fontsource/league-gothic` | SIL Open Font License 1.1 |
| Lora | `@fontsource/lora` | SIL Open Font License 1.1 |
| Montserrat | `@fontsource/montserrat` | SIL Open Font License 1.1 |
| MuseoModerno | `@fontsource/museomoderno` | SIL Open Font License 1.1 |
| Oswald | `@fontsource/oswald` | SIL Open Font License 1.1 |
| Pacifico | `@fontsource/pacifico` | SIL Open Font License 1.1 |
| Parisienne | `@fontsource/parisienne` | SIL Open Font License 1.1 |
| Permanent Marker | `@fontsource/permanent-marker` | Apache License 2.0 |
| Playfair Display | `@fontsource/playfair-display` | SIL Open Font License 1.1 |
| Poppins | `@fontsource/poppins` | SIL Open Font License 1.1 |
| Sacramento | `@fontsource/sacramento` | SIL Open Font License 1.1 |
| Satisfy | `@fontsource/satisfy` | Apache License 2.0 |
| Shrikhand | `@fontsource/shrikhand` | SIL Open Font License 1.1 |
| Yellowtail | `@fontsource/yellowtail` | Apache License 2.0 |
| Geist Sans | `@fontsource/geist-sans` | SIL Open Font License 1.1 |
-3
View File
@@ -112,9 +112,6 @@ data:
{{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }}
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
{{- end }}
{{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }}
-1
View File
@@ -108,7 +108,6 @@ env:
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on.
# TREK_PLACES_URL: ""
# GOOGLE_LIST_DATA_BASE: ""
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com.
-67
View File
@@ -1,67 +0,0 @@
import { BOOK_FONT_FACES, BOOK_FONTS_PRECACHED } from '@trek/shared';
/**
* Which font files the service worker installs with the app.
*
* The precache takes every woff and woff2 the build emits, and with the book's
* catalogue at thirty four families that is every weight, both styles and
* every script subset of each: megabytes fetched at install by every user,
* most of it for faces no book of theirs is set in. So the book's faces are
* held to this:
*
* - The faces the app itself draws (Poppins and MuseoModerno for the chrome,
* imported in src/main.tsx) stay exactly as they were.
* - The faces the book shipped with before the catalogue grew
* (`BOOK_FONTS_PRECACHED`) keep their latin woff2, so a book set in them
* still opens offline before Studio was ever opened online.
* - Every other file of a book family, woff, other subsets, new families,
* real italics, is left to the `book-fonts` runtime cache in vite.config.js,
* which the Studio fills with the faces of each book it opens
* (src/components/Studio/bookFontWarm.ts).
*
* Shared by vite.config.js and its test, which reads the filter against file
* names as the build writes them.
*/
/** The faces src/main.tsx imports for the app chrome, which the book shares; bookFontPrecache.test.ts checks this copy. */
export const APP_FACES = {
poppins: [300, 400, 500, 600, 700],
museomoderno: [400, 700, 800],
};
/** The book packages, longest name first, so `dm-serif-display` is never read as `dm-…`. */
const BOOK_PACKAGES = [...new Set(Object.values(BOOK_FONT_FACES).map((f) => f.pkg))].sort(
(a, b) => b.length - a.length
);
const PRECACHED = new Map(
Object.entries(BOOK_FONTS_PRECACHED).map(([id, weights]) => [BOOK_FONT_FACES[id].pkg, new Set(weights)])
);
/**
* A font file as the build names it, `<package>-<subset>-<weight>-<style>-<hash>.<ext>`,
* read against the book's packages; null for any other file.
*/
export function bookFontFile(url) {
const name = url.split('/').pop() ?? '';
const match = /^(.+)-(\d{3})-(normal|italic)-[\w-]+\.(woff2?)$/.exec(name);
if (!match) return null;
const [, head, weight, style, ext] = match;
const pkg = BOOK_PACKAGES.find((p) => head.startsWith(`${p}-`));
if (!pkg) return null;
return { pkg, subset: head.slice(pkg.length + 1), weight: Number(weight), italic: style === 'italic', ext };
}
/** Whether a file of the build goes into the install-time precache. */
export function keepInPrecache(url) {
const file = bookFontFile(url);
if (!file) return true;
if (!file.italic && APP_FACES[file.pkg]?.includes(file.weight)) return true;
const kept = PRECACHED.get(file.pkg);
return !file.italic && file.subset === 'latin' && file.ext === 'woff2' && !!kept?.has(file.weight);
}
/** The precache manifest without the book faces left to the runtime cache: a Workbox `manifestTransforms` entry. */
export function withoutRuntimeFonts(entries) {
return { manifest: entries.filter((entry) => keepInPrecache(entry.url)), warnings: [] };
}
+3 -3
View File
@@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request,
// ── Studio ────────────────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.st-rail')
const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true })
const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true })
const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
const inspector = (page: Page) => page.locator('.st-inspector')
const studioMenu = (page: Page) => page.locator('.st-menu').last()
@@ -1300,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => p.locator('.st-head-close'),
target: p => p.locator('.st-back'),
},
],
},
@@ -1431,7 +1431,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
{
prepare: async p => {
await sidePanel(p).getByRole('tab', { name: /Entries/ }).click()
await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click()
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
await settle(p)
},
+1 -28
View File
@@ -47,41 +47,14 @@
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
"@fontsource/alfa-slab-one": "^5.3.0",
"@fontsource/allura": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/big-shoulders-display": "^5.3.0",
"@fontsource/bodoni-moda": "^5.3.0",
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/caveat": "^5.3.0",
"@fontsource/cormorant-garamond": "^5.3.0",
"@fontsource/dancing-script": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0",
"@fontsource/dm-serif-display": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/homemade-apple": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/oswald": "^5.3.0",
"@fontsource/pacifico": "^5.3.0",
"@fontsource/parisienne": "^5.3.0",
"@fontsource/permanent-marker": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@fontsource/sacramento": "^5.3.0",
"@fontsource/satisfy": "^5.3.0",
"@fontsource/shrikhand": "^5.3.0",
"@fontsource/yellowtail": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
@@ -108,7 +81,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"tz-lookup": "^6.1.25",
"zod": "^4.6.7",
"zod": "^4.6.5",
"zustand": "^5.0.15"
},
"devDependencies": {
+12 -12
View File
@@ -1,26 +1,26 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 385,
"@typescript-eslint/no-misused-promises": 849,
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 854,
"no-console": 88,
"complexity": 222,
"complexity": 226,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130,
"max-params": 32,
"trek/no-swallowed-catch": 131,
"max-params": 33,
"trek/store-without-selector": 67,
"react-hooks/refs": 556,
"react-hooks/set-state-in-effect": 194,
"react-hooks/refs": 557,
"react-hooks/set-state-in-effect": 195,
"@typescript-eslint/no-floating-promises": 165,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124,
"@typescript-eslint/no-unused-vars": 126,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
"max-lines-per-function": 132,
"react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 45,
"react-refresh/only-export-components": 46,
"unused-disable-directive": 6,
"max-depth": 21,
"react-hooks/void-use-memo": 1,
@@ -34,8 +34,8 @@
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1931,
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1932,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+1 -9
View File
@@ -69,15 +69,6 @@
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
},
{
"feature": "AI assistant home chat",
"hooks": ["src/components/Assistant/useAssistantHome.ts"],
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
"mobile": [
"src/mobile/screens/assistant/MAssistantHomeScreen.tsx",
"src/mobile/screens/dashboard/MDashboard.tsx"
]
},
{
"feature": "Assistant hints",
"hooks": ["src/components/Assistant/useAssistantHints.ts"],
@@ -601,6 +592,7 @@
"hooks": ["src/components/Planner/usePlaceActions.ts"],
"desktop": [
"src/components/Planner/PlaceInspector.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/pages/tripPlanner/useTripPlannerPage.ts"
],
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
+2
View File
@@ -1123,6 +1123,8 @@
"src/components/shared/PasswordChecklist.tsx",
"src/components/shared/PlaceAvatar.test.tsx",
"src/components/shared/PlaceAvatar.tsx",
"src/components/shared/PlaceAvatarUpload.test.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/components/shared/PluginIcon.test.tsx",
"src/components/shared/PluginIcon.tsx",
"src/components/shared/PublicLanguagePicker.test.tsx",
+5 -7
View File
@@ -117,6 +117,11 @@ export const DYNAMIC_ALLOWED = [
template: 'settings.${mode}',
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
},
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
file,
template: 'journey.studio.${key}',
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
})),
{
file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
template: 'reservations.${s}',
@@ -139,13 +144,6 @@ export const DYNAMIC_ALLOWED = [
'memories.* key in en',
}))
),
{
file: 'components/Studio/stickerLibrary.ts',
template: 'journey.studio.sticker.${id}',
because:
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
'sticker the library holds against en',
},
];
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
+1 -1
View File
@@ -26,7 +26,7 @@
"packing.ts": 6,
"pdf.ts": 1,
"perm.ts": 4,
"places.ts": 9,
"places.ts": 10,
"planner.ts": 53,
"reservations.ts": 21,
"roadtrip.ts": 36,
+1 -1
View File
@@ -38,8 +38,8 @@
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
"src/components/Studio/StudioShell.tsx": 1,
"src/components/Studio/StudioSidebar.tsx": 1,
"src/components/Studio/TravelElements.tsx": 1,
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
"src/components/Todo/useTodoList.ts": 1,
"src/components/shared/ConfirmDialog.tsx": 1,
"src/components/shared/CountryFlag.tsx": 1,
+1
View File
@@ -116,6 +116,7 @@
"src/components/Vacay/useVacayCalendarLogic.ts",
"src/components/Vacay/useVacaySettings.ts",
"src/components/Vacay/useVacayUserPicker.ts",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/hooks/useAirtrailConnection.ts",
"src/hooks/useDawarichConnection.ts",
"src/hooks/useDawarichSuggestions.ts",
+5 -2
View File
@@ -18,6 +18,7 @@
"components/Journey/JourneyMap.tsx": 1,
"components/Journey/JourneyMapGL.tsx": 2,
"components/Journey/MobileEntryView.tsx": 1,
"components/Journey/PhotoLightbox.tsx": 3,
"components/Journey/journeyPhotoLayer.ts": 1,
"components/Layout/Navbar.tsx": 1,
"components/Layout/OfflineBanner.tsx": 1,
@@ -51,12 +52,13 @@
"components/Studio/BookSheetsView.tsx": 3,
"components/Studio/PageNumbers.tsx": 1,
"components/Studio/PeerCursors.tsx": 1,
"components/Studio/PhotoView.tsx": 4,
"components/Studio/SpreadView.tsx": 4,
"components/Studio/SpreadView.tsx": 8,
"components/Studio/StudioCanvas.tsx": 12,
"components/Studio/StudioElementsPanel.tsx": 1,
"components/Studio/StudioExport.tsx": 1,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioSavedLayouts.tsx": 1,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 4,
"components/Studio/TravelPreview.tsx": 1,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
@@ -71,6 +73,7 @@
"components/shared/DetailShell.tsx": 1,
"components/shared/ElevationProfile.tsx": 2,
"components/shared/NavigationMenu.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 1,
"components/shared/SlidingTabs.tsx": 3,
"components/shared/StarRating.tsx": 3,
-2
View File
@@ -1,7 +1,5 @@
{
"components/Studio/LineHandles.tsx": 1,
"components/Studio/StudioSelectionChrome.tsx": 2,
"components/Studio/inspector/FocalPad.tsx": 1,
"mobile/components/trek3d/MascotClip.tsx": 1,
"pages/tripPlanner/PlanMapStage.tsx": 4
}
+5 -5
View File
@@ -8,23 +8,23 @@
"src/components/Map/MapView.tsx": 1399,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1076,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2953,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1157,
"src/components/Planner/PlaceInspector.tsx": 1290,
"src/components/Planner/PlaceFormModal.tsx": 1193,
"src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/TripPlannerPage.tsx": 1131,
"src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 1936
"src/styles/studio.css": 2087
}
+3 -1
View File
@@ -51,7 +51,7 @@
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
"src/components/Planner/PlaceFormModal.test.tsx": 3,
"src/components/Planner/PlaceFormModal.tsx": 1,
"src/components/Planner/PlaceInspector.tsx": 16,
"src/components/Planner/PlaceInspector.tsx": 17,
"src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1,
"src/components/Planner/PlacesSidebar.tsx": 1,
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
@@ -82,6 +82,7 @@
"src/components/Settings/NotificationsTab.test.tsx": 1,
"src/components/Settings/PasskeysSection.tsx": 1,
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
"src/components/Studio/StudioInspector.tsx": 1,
"src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2,
@@ -96,6 +97,7 @@
"src/components/shared/CustomSelect.test.tsx": 1,
"src/components/shared/PlaceAvatar.test.tsx": 2,
"src/components/shared/PlaceAvatar.tsx": 1,
"src/components/shared/PlaceAvatarUpload.test.tsx": 1,
"src/hooks/useDawarichSuggestions.test.ts": 1,
"src/mobile/screens/admin/MAdminAddonManager.tsx": 2,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6,
+5 -4
View File
@@ -100,13 +100,13 @@
"components/Roadtrip/serviceMarker.ts": 1,
"components/Settings/useAppearanceEditor.ts": 5,
"components/Studio/BookSheetsView.tsx": 2,
"components/Studio/PhotoView.tsx": 2,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/SpreadView.tsx": 3,
"components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 2,
"components/Studio/StudioShell.tsx": 2,
"components/Studio/StudioSidebar.tsx": 2,
"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,
@@ -132,6 +132,7 @@
"components/shared/Modal.tsx": 3,
"components/shared/NavigationMenu.tsx": 1,
"components/shared/NoteColorPicker.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 3,
"components/shared/Skeleton.tsx": 2,
"components/shared/Spinner.tsx": 2,
@@ -251,7 +252,7 @@
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2,
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 23,
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 27,
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
+1 -2
View File
@@ -40,8 +40,7 @@
"components/Settings/AppearanceSettingsTab.tsx": 1,
"components/Settings/PasskeysSection.tsx": 1,
"components/Studio/BookSheetsView.tsx": 1,
"components/Studio/PhotoView.tsx": 1,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/SpreadView.tsx": 2,
"components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 6,
+3 -4
View File
@@ -67,7 +67,6 @@ const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePag
const TourSaveToCollectionHost = lazyWithRetry(() => import('./components/Tours/collection/TourSaveToCollectionHost'))
// The desktop assistant window: its own chunk, and nothing at all until a connection is set up.
const AssistantHost = lazyWithRetry(() => import('./components/Assistant/AssistantHost'))
const MAssistantHomeScreen = lazyWithRetry(() => import('./mobile/screens/assistant/MAssistantHomeScreen'))
// The ten phone screens are chunks of their own, alongside the desktop pages
// rather than inside them. Each page used to import its M screen statically and
@@ -411,10 +410,10 @@ export default function App() {
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
{!hideAuthedWidgets && keepingTour && <ErrorBoundary boundaryId="widget:keep-tour" fallback={null}><Suspense fallback={null}><TourSaveToCollectionHost /></Suspense></ErrorBoundary>}
{/* The phone has its own assistant screen inside the trip, and the home chat's outside it. */}
{!hideAuthedWidgets && isAuthenticated && (
{/* The phone has its own assistant screen inside the trip. */}
{!hideAuthedWidgets && isAuthenticated && !isPhone && (
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
<Suspense fallback={null}>{isPhone ? <MAssistantHomeScreen /> : <AssistantHost />}</Suspense>
<Suspense fallback={null}><AssistantHost /></Suspense>
</ErrorBoundary>
)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
-42
View File
@@ -165,46 +165,4 @@ describe('assistantApi JSON calls', () => {
await assistantApi.stop(journey);
expect(hits).toEqual(['threads', 'undo', 'stop']);
});
it('FE-AI-API-009: the home scope reaches the routes outside any trip', async () => {
const home = { kind: 'home' } as const;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => streamResponse('data: [DONE]\n\n'));
const signal = new AbortController().signal;
await assistantApi.chat(home, BODY, signal);
await assistantApi.decide(home, 7, { locale: 'en', timeZone: 'UTC', decisions: [] }, signal);
expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([
'/api/assistant/chat',
'/api/assistant/threads/7/decisions',
]);
const hits: string[] = [];
server.use(
http.get('/api/assistant/threads', () => {
hits.push('threads');
return HttpResponse.json({ threads: [] });
}),
http.get('/api/assistant/threads/3', () => {
hits.push('thread');
return HttpResponse.json({ thread: { id: 3, title: 't', lastMessageAt: 'x' }, messages: [] });
}),
http.delete('/api/assistant/threads/3', () => {
hits.push('delete');
return HttpResponse.json({ success: true });
}),
http.post('/api/assistant/tool-calls/:id/undo', () => {
hits.push('undo');
return HttpResponse.json({ undone: true });
}),
http.post('/api/assistant/stop', () => {
hits.push('stop');
return HttpResponse.json({ success: true });
})
);
await assistantApi.threads(home);
await assistantApi.thread(home, 3);
await assistantApi.deleteThread(home, 3);
await assistantApi.undo(home, 'c1');
await assistantApi.stop(home);
expect(hits).toEqual(['threads', 'thread', 'delete', 'undo', 'stop']);
});
});
+3 -13
View File
@@ -18,7 +18,6 @@ import {
type AssistantChatRequest,
type AssistantDecisionsRequest,
type AssistantErrorCode,
type AssistantInsertFoundPlaceRequest,
type AssistantRenderUploadResponse,
type AssistantScope,
type AssistantShareRequest,
@@ -103,16 +102,14 @@ const tripBase = (tripId: number) => `/trips/${tripId}/assistant`;
/**
* What a conversation call is about: a trip's id (the planner's chat), or a
* scope, which also reaches a journey's chat in TREK Studio and the home chat
* outside any one trip. Threads, turns, decisions, undo and stop exist under
* all three; files, found places, proposals and sharing belong to a trip and
* take its id.
* scope, which also reaches a journey's chat in TREK Studio. Threads, turns,
* decisions, undo and stop exist under both; files, found places, proposals
* and sharing belong to a trip and take its id.
*/
export type AssistantTarget = number | AssistantScope;
function scopeBase(target: AssistantTarget): string {
if (typeof target === 'number') return tripBase(target);
if (target.kind === 'home') return '/assistant';
return target.kind === 'trip' ? tripBase(target.tripId) : `/journeys/${target.journeyId}/assistant`;
}
@@ -146,13 +143,6 @@ export const assistantApi = {
body: AssistantAddFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
// "+ Tour": a found place taken into the saved tour open in the planner, held as a change like "+ Day 3".
insertFoundPlace: (
tripId: number,
threadId: number,
body: AssistantInsertFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places/tour`, body).then((r) => r.data),
// A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
takeProposal: (
tripId: number,
@@ -22,12 +22,7 @@ vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
const SETTINGS: AiAdminSettings = {
policy: 'instance_or_user',
dailyTurns: 50,
webSearch: true,
retention: { mode: 'days', days: 90 },
};
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
const LIVE: AiAdminConnectionResponse = {
connection: {
scope: 'instance',
@@ -107,17 +102,6 @@ describe('the connection and the assistant', () => {
expect(screen.getByText('Keep chats')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-001b: the web search is on as it ships and is switched off on the assistant tab', async () => {
const user = userEvent.setup();
renderDialog();
await user.click(await screen.findByRole('button', { name: 'AI Assistant' }));
const toggle = screen.getByRole('button', { name: 'Web search' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText(/through the search of the AI provider it talks to/)).toBeInTheDocument();
await user.click(toggle);
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, webSearch: false });
});
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
const user = userEvent.setup();
renderDialog();
@@ -1,7 +1,6 @@
import { Check, Loader2, Sparkles } from 'lucide-react';
import React, { useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import ToggleSwitch from '../../Settings/ToggleSwitch';
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
@@ -137,7 +136,7 @@ function InstanceConnection({ A }: { A: AiAdmin }) {
);
}
/** The assistant's own limits: turns a day while the instance pays, how long chats are kept, whether it searches the web. */
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
function AssistantLimits({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const fieldId = useId();
@@ -171,19 +170,6 @@ function AssistantLimits({ A }: { A: AiAdmin }) {
size="sm"
/>
</EditorField>
{A.settings && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-3">
<span className="text-body font-medium text-content">{t('assistant.admin.webSearch')}</span>
<ToggleSwitch
on={A.settings.webSearch}
onToggle={() => A.setWebSearch(!A.settings?.webSearch)}
label={t('assistant.admin.webSearch')}
/>
</div>
<SettingsHint>{t('assistant.admin.webSearchHint')}</SettingsHint>
</div>
)}
</div>
);
}
@@ -30,12 +30,7 @@ const wrapper = ({ children }: { children: ReactNode }) =>
createElement(TranslationProvider, null, children)
);
const SETTINGS: AiAdminSettings = {
policy: 'instance_or_user',
dailyTurns: 50,
webSearch: true,
retention: { mode: 'days', days: 90 },
};
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
const NONE: AiAdminConnectionResponse = { connection: null, prefill: null };
const LIVE: AiAdminConnectionResponse = {
connection: {
@@ -40,7 +40,7 @@ function retentionOf(value: string): AiRetention {
return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) };
}
/** Policy, daily limit, retention and web search: each change is saved at once and taken back when the server refuses it. */
/** Policy, daily limit and retention: each change is saved at once and taken back when the server refuses it. */
function useAdminSettings(onSaved: () => void) {
const { t } = useTranslation();
const toast = useToast();
@@ -92,7 +92,6 @@ function useAdminSettings(onSaved: () => void) {
{ value: FOREVER, label: t('assistant.admin.retentionForever') },
],
setRetention: (value: string) => void save({ retention: retentionOf(value) }),
setWebSearch: (webSearch: boolean) => void save({ webSearch }),
};
}
@@ -3,7 +3,6 @@ import { ArrowUp, CalendarDays, MapPin, Paperclip, Square, X } from 'lucide-reac
import { type ReactNode, useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import PlaceAvatar from '../shared/PlaceAvatar';
import Tooltip from '../shared/Tooltip';
@@ -46,11 +45,7 @@ export default function AssistantComposer({ tripId, tripTitle }: { tripId: numbe
const empty = useAuiState((s) => s.composer.isEmpty);
const { take, busy, accept, canAttach } = useAssistantAttach(tripId);
const fileInput = useRef<HTMLInputElement>(null);
// The home chat is about no one trip, so its placeholder names none.
const fallback =
tripId === HOME_CHAT_KEY
? t('assistant.home.placeholder')
: t('assistant.composer.placeholder', { trip: tripTitle });
const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
const placeholder = useTypewriterPlaceholder(
suggestions.map((s) => s.prompt),
fallback,
@@ -1,17 +1,5 @@
import {
Ellipsis,
History,
House,
Lightbulb,
LightbulbOff,
Luggage,
NotebookPen,
Plus,
Settings,
Trash2,
X,
} from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useRef, useState } from 'react';
import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n';
@@ -24,9 +12,7 @@ import Tooltip from '../shared/Tooltip';
import { PopoverListPanel } from '../shared/dialogParts';
import { usePopoverList } from '../shared/usePopoverList';
import AssistantMemoryDialog from './AssistantMemoryDialog';
import AssistantTripPicker from './AssistantTripPicker';
import { AI_SETTINGS_PATH } from './assistantParts';
import type { AssistantChatTrip } from './assistantViewContext';
import { useAssistantHintsSetting } from './useAssistantHints';
import { useAssistantLiveStep } from './useAssistantLiveStep';
import type { useAssistantThread } from './useAssistantThread';
@@ -43,16 +29,6 @@ interface AssistantHeaderProps {
onClose: () => void;
/** Moves the window: spread on the title, which is the part of the header to grab. */
dragProps: HTMLAttributes<HTMLElement>;
/**
* The way between the home chat and one trip's: the home chat picks a trip
* (`onPick`), a trip's chat opened from outside it goes back (`onHome`).
*/
tripSwitch?: TripSwitch;
}
interface TripSwitch {
onPick?: (trip: AssistantChatTrip) => void;
onHome?: () => void;
}
/**
@@ -63,14 +39,7 @@ interface TripSwitch {
* keeps the conversation, and an answer being written goes on. The model is not picked here;
* that lives in the settings. Notes are a trip's: a journey's chat in TREK Studio shows no notes button.
*/
export default function AssistantHeader({
tripId,
thread,
tripTitle,
onClose,
dragProps,
tripSwitch,
}: AssistantHeaderProps) {
export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
const { t, locale } = useTranslation();
const live = useAssistantLiveStep(tripId);
const memory = useTripMemory(tripId);
@@ -79,7 +48,6 @@ export default function AssistantHeader({
const [memoryOpen, setMemoryOpen] = useState(false);
const navigate = useNavigate();
const menu = useContextMenu();
const moreRef = useRef<HTMLButtonElement>(null);
const [confirming, setConfirming] = useState(false);
const threads = thread.threads ?? [];
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
@@ -96,14 +64,6 @@ export default function AssistantHeader({
run();
};
// Closed by Escape or a pick while it holds the focus, the menu hands it back to its button, so the keys
// go on reaching the window; a click or a scroll elsewhere leaves the focus where it went.
const closeMenu = () => {
const fromMenu = document.activeElement?.closest('[role="menu"]') != null;
menu.close();
if (fromMenu) moreRef.current?.focus();
};
const openMenu = (e: MouseEvent) =>
menu.open(
e,
@@ -143,7 +103,11 @@ export default function AssistantHeader({
<div className="min-w-0 flex-1">
{thread.isRunning ? (
// While it works, the line over the title says what it does right now.
<p role="status" className="m-0 flex min-w-0 items-center text-caption font-medium">
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption font-medium">
<span
aria-hidden="true"
className="trek-assistant-pulse h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
/>
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
</p>
) : (
@@ -162,7 +126,6 @@ export default function AssistantHeader({
onPointerDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{tripSwitch && <TripSwitchButton {...tripSwitch} />}
{tripChat && (
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
<button
@@ -196,12 +159,10 @@ export default function AssistantHeader({
</Tooltip>
<Tooltip label={t('assistant.window.menu')}>
<button
ref={moreRef}
type="button"
onClick={openMenu}
aria-label={t('assistant.window.menu')}
aria-haspopup="menu"
aria-expanded={menu.menu !== null}
className={ICON_BUTTON}
>
<Ellipsis size={14} aria-hidden="true" />
@@ -229,7 +190,6 @@ export default function AssistantHeader({
icon={null}
label={th.title}
active={th.id === thread.threadId}
current={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))}
trailing={
<span className="flex-shrink-0 text-caption text-content-faint">
@@ -246,7 +206,7 @@ export default function AssistantHeader({
)}
</PopoverListPanel>
<ContextMenu menu={menu.menu} onClose={closeMenu} />
<ContextMenu menu={menu.menu} onClose={menu.close} />
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<ConfirmDialog
isOpen={confirming}
@@ -262,49 +222,3 @@ export default function AssistantHeader({
</header>
);
}
/**
* Between the home chat and a trip's: from the home chat a list of the user's
* trips (the running and the next one first) opens that trip's chat in the
* window; from a trip's chat opened outside it, one press goes back home.
*/
function TripSwitchButton({ onPick, onHome }: TripSwitch) {
const { t } = useTranslation();
const list = usePopoverList(0, { estimatedHeight: 360 });
const { open, triggerRef, listId, toggle, close } = list;
if (onHome) {
return (
<Tooltip label={t('assistant.home.back')}>
<button type="button" onClick={onHome} aria-label={t('assistant.home.back')} className={ICON_BUTTON}>
<House size={13} aria-hidden="true" />
</button>
</Tooltip>
);
}
if (!onPick) return null;
return (
<>
<Tooltip label={t('assistant.home.switch')}>
<button
ref={triggerRef}
type="button"
onClick={toggle}
aria-label={t('assistant.home.switch')}
aria-expanded={open}
aria-controls={open ? listId : undefined}
className={ICON_BUTTON}
>
<Luggage size={13} aria-hidden="true" />
</button>
</Tooltip>
<PopoverListPanel list={list} label={t('assistant.picker.title')} className="flex w-72 flex-col">
<AssistantTripPicker
onPick={({ id, title }) => {
close();
onPick({ id, title });
}}
/>
</PopoverListPanel>
</>
);
}
@@ -17,8 +17,7 @@ function useRouteTripId(): number | null {
/**
* The desktop assistant, mounted once above the routes so a conversation
* keeps going from page to page. Inside a trip it is about that trip, outside
* one it is the home chat about all of them (AssistantWindow); what a
* keeps going from page to page. Inside a trip it is about that trip; what a
* card or an applied change opens goes through the planner's links: "Show on
* map" opens the planner on the change (`?place=`, `?day=`), the same way a
* notification opens a place, a tab of the trip opens as `?tab=` (a list's
@@ -101,14 +101,6 @@ describe('AssistantLauncher', () => {
act(() => useAssistantStore.getState().open());
expect(button).not.toHaveAttribute('aria-busy');
});
it('FE-AI-LAUNCH-008: the floating button breathes for an answer of the home chat being written too', () => {
const home = assistantChatKey({ kind: 'home' });
const running = { ...chatOf(useAssistantStore.getState(), home), isRunning: true };
useAssistantStore.setState({ status: STATUS, byTrip: { [home]: running } });
render(<AssistantLauncher variant="floating" />);
expect(screen.getByRole('button', { name: 'Assistant' })).toHaveAttribute('aria-busy', 'true');
});
});
describe('the shortcut', () => {
@@ -15,7 +15,7 @@ export const LAUNCHER_SIZE = 48;
/**
* The way into the assistant: a button in the navbar, and in the planner a
* round one floating at the bottom end of the screen. Both open and close the
* window; outside a trip it opens on the home chat. Nothing shows until the
* window; outside a trip it opens on the trip picker. Nothing shows until the
* assistant has a connection this user can use: no setup prompt in its place.
* The floating one carries a dot while the trip has a warning the user has
* not been shown; opening the chat counts them as seen.
@@ -35,11 +35,11 @@ export default function AssistantLauncher({
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open && !s.window.minimized);
const toggle = useAssistantStore((s) => s.toggle);
// An answer still being written in a closed window: the floating button breathes until it is opened. A
// trip's chat and the home chat count: an answer still running in the Studio is not one this window opens on.
// An answer still being written in a closed window: the floating button breathes until it is opened. Only a
// trip's chat counts: an answer still running in the Studio is not one this window opens on.
const busy = useAssistantStore((s) =>
!s.window.open || s.window.minimized
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind !== 'journey')
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind === 'trip')
: false
);
const keys = useAssistantShortcutKeys();
@@ -11,9 +11,7 @@ import AssistantFollowUps from './AssistantFollowUps';
import AssistantMarkdown from './AssistantMarkdown';
import AssistantMentionText from './AssistantMentionText';
import AssistantPlaceStrip from './AssistantPlaceStrip';
import AssistantRemembered from './AssistantRemembered';
import AssistantShareDialog from './AssistantShareDialog';
import AssistantSources from './AssistantSources';
import AssistantToolSteps from './AssistantToolSteps';
import AssistantWeatherStrip from './AssistantWeatherStrip';
import { messageBlocks } from './assistantParts';
@@ -131,10 +129,6 @@ function Answer({ message }: { message: StoredMessage }) {
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
}
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
if (block.kind === 'remembered') {
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
}
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
return <AssistantChangeCard key={block.key} items={block.items} />;
})}
@@ -1,10 +1,12 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { Check, MapPin, Plus, Route, Star } from 'lucide-react';
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
import { createElement, type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { getCategoryIcon } from '../shared/categoryIcons';
import { PopoverListPanel } from '../shared/dialogParts';
import { Tooltip } from '../shared/Tooltip';
import { usePopoverList } from '../shared/usePopoverList';
import { useAssistantView } from './assistantViewContext';
import type { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
import { useFoundPlacePhoto } from './useFoundPlacePhoto';
@@ -14,32 +16,110 @@ type Strip = ReturnType<typeof useAssistantPlaceStrip>;
const ACTION =
'inline-flex items-center gap-1 rounded-lg bg-surface-secondary text-caption font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50 disabled:hover:bg-surface-secondary';
/** "Add to trip": opens the planner's place form filled in with the place, its details loading beside it. */
function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
/** "+ Day 3" for the day in focus, a list of the trip's days beside it; "Pick a day" when no day is in focus. */
function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
const { t } = useTranslation();
return (
<button type="button" onClick={() => strip.open(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
<Plus size={12} aria-hidden="true" />
<span className="truncate">{t('assistant.place.add')}</span>
</button>
);
}
const { days, focusDay, disabled, blockedKey, adding } = strip;
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
});
const { open, toggle, close, triggerRef, listId } = list;
const height = tall ? 'h-10' : 'h-7';
const busy = adding === place.ref;
const pick = (dayId: number) => {
close();
void strip.suggest(place, dayId);
};
const buttons = (
<span className="inline-flex min-w-0 gap-px">
{focusDay ? (
<>
<button
type="button"
disabled={disabled}
onClick={() => void strip.suggest(place, focusDay.id)}
aria-label={t('assistant.place.addTo', { day: focusDay.label })}
className={`${ACTION} ${height} rounded-e-none px-2`}
>
{busy ? (
<LoaderCircle size={12} aria-hidden="true" className="animate-spin" />
) : (
<Plus size={12} aria-hidden="true" />
)}
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
</button>
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
aria-label={t('assistant.place.pickDay')}
className={`${ACTION} ${height} rounded-s-none px-1.5`}
>
<ChevronDown size={12} aria-hidden="true" />
</button>
</>
) : (
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
className={`${ACTION} ${height} px-2`}
>
<Plus size={12} aria-hidden="true" />
<span className="truncate">{t('assistant.place.pickDay')}</span>
<ChevronDown size={12} aria-hidden="true" className="text-content-faint" />
</button>
)}
</span>
);
/** "+ Tour": takes the place into the saved tour open in the planner as a stop, held as a change in the chat. */
function TourButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
const { t } = useTranslation();
if (!strip.tour) return null;
return (
<Tooltip label={t('assistant.place.addToTourHint', { name: strip.tour.name })} placement="top">
<button
type="button"
onClick={() => strip.addToTour(place)}
className={`${ACTION} ${tall ? 'h-10' : 'h-7'} min-w-0 px-2`}
>
<Route size={12} aria-hidden="true" className="flex-shrink-0" />
<span className="truncate">{t('assistant.place.addToTour')}</span>
</button>
</Tooltip>
<>
{blockedKey ? (
<Tooltip label={t(blockedKey)} placement="top">
{/* A disabled button gets no pointer events; the wrapper carries the reason. */}
<span className="inline-flex min-w-0">{buttons}</span>
</Tooltip>
) : (
buttons
)}
{/* A grid of day tiles, not a list: a month-long trip still fits in a few rows. */}
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="w-[15.5rem] p-2">
<div className="grid grid-cols-7 gap-1">
{days.map((day) => {
const current = day.id === focusDay?.id;
return (
<Tooltip key={day.id} label={day.label} placement="top">
<button
type="button"
onClick={() => pick(day.id)}
aria-label={day.label}
aria-pressed={current}
className={`flex h-10 flex-col items-center justify-center rounded-lg leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
current ? 'bg-accent text-accent-text' : 'text-content hover:bg-surface-hover'
}`}
>
<span className="text-body font-semibold tabular-nums">{day.n}</span>
{day.weekday && (
<span className={`mt-0.5 text-caption ${current ? 'text-accent-text' : 'text-content-faint'}`}>
{day.weekday}
</span>
)}
</button>
</Tooltip>
);
})}
</div>
</PopoverListPanel>
</>
);
}
@@ -139,7 +219,7 @@ function PlaceAction({
</span>
);
}
return strip.canAdd ? <AddButton place={place} strip={strip} tall={tall} /> : null;
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
}
/**
@@ -147,10 +227,9 @@ function PlaceAction({
* with the category it most likely belongs in on it (or that category's icon
* on its colour), its name, address and rating, whether it is open now while
* the trip runs, how far it is from the last stop of the day in focus, and one
* way on: "Add to trip" for a new place, "On map" for one the trip has. A click
* on a new place opens the place form filled in with it (one the trip has, or
* any where nothing can be added, shows on the map), and resting the pointer on
* it pins it there for a moment.
* way on: "+ Day 3" for a new place, "On map" for one the trip has. A click on
* the card shows it on the map, and resting the pointer on it pins it there
* for a moment.
*/
export default function AssistantPlaceCard({
place,
@@ -180,7 +259,7 @@ export default function AssistantPlaceCard({
>
<button
type="button"
onClick={() => (place.inTripPlaceId === null && strip.canAdd ? strip.open(place) : strip.show(place))}
onClick={() => strip.show(place)}
onFocus={() => strip.hover(place)}
onBlur={() => strip.hover(null)}
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
@@ -203,9 +282,8 @@ export default function AssistantPlaceCard({
)}
</span>
</button>
<div className="mt-auto flex min-w-0 gap-1.5 px-3 pb-3 pt-2.5">
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
<TourButton place={place} strip={strip} tall={tall} />
</div>
</li>
);
@@ -2,8 +2,9 @@
* FE-AI-PLACES-001 to FE-AI-PLACES-010
*
* The strip of places a search found: what a card says, that its picture only
* ever comes from TREK's photo proxy, "Add to trip" opening the place form on
* the day in focus, the map's part, and no "Add to trip" in a journey's chat.
* ever comes from TREK's photo proxy, "+ Day N" for the day in focus or a
* picked one, why it waits while a change is undecided, the map's part, and
* no "+ Day" in a journey's chat.
*/
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -21,7 +22,6 @@ import { fetchPhoto } from '../../services/photoService';
import { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
import { setOpenTour } from '../../store/openTourStore';
import { useTripStore } from '../../store/tripStore';
import { setForcedOffline } from '../../sync/networkMode';
import AssistantPlaceStrip from './AssistantPlaceStrip';
@@ -67,6 +67,7 @@ const answer = (parts: AssistantMessage['parts'] = []): AssistantMessage => ({
parts,
});
const addFoundPlace = vi.fn(async () => {});
const requestCamera = vi.fn();
const setPreviewPin = vi.fn();
const onRevealMap = vi.fn();
@@ -82,6 +83,7 @@ function seed({ focusDayId = 31, messages = [] as AssistantMessage[] } = {}) {
categories: [buildCategory({ id: 5, name: 'Restaurant', icon: 'Utensils' })],
});
useAssistantStore.setState({
addFoundPlace,
requestCamera,
setPreviewPin,
byTrip: {
@@ -106,7 +108,6 @@ afterEach(() => {
useAuthStore.setState(AUTH, true);
globalThis.IntersectionObserver = RealObserver;
setForcedOffline(false);
setOpenTour(null);
vi.clearAllMocks();
});
@@ -164,30 +165,34 @@ describe('AssistantPlaceStrip', () => {
expect(fetchPhoto).toHaveBeenCalledWith('good', 'good', 41.889, 12.47, 'Trattoria s1:0', expect.any(Function));
});
it('FE-AI-PLACES-004: "Add to trip" opens the place form with the place, on the day in focus', () => {
it('FE-AI-PLACES-004: "+ Day 1" suggests the place for the day in focus', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: 'Add to trip' }));
expect(onRevealMap).toHaveBeenCalled();
expect(useAssistantStore.getState().placeDraft).toMatchObject({ tripId: TRIP, dayId: 31, place: { ref: 's1:0' } });
fireEvent.click(screen.getByRole('button', { name: /^Add to/ }));
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 31);
});
it('FE-AI-PLACES-005: a click on a new place opens the form too, without a day when none is in focus', () => {
it('FE-AI-PLACES-005: another day is picked from the list, and without a focus the list is the only way', () => {
seed({ focusDayId: 0 });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByText('Trattoria s1:0'));
expect(useAssistantStore.getState().placeDraft).toMatchObject({ dayId: null, place: { ref: 's1:0' } });
expect(requestCamera).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /Pick a day/ }));
fireEvent.click(screen.getByRole('button', { name: /Nov 3/ }));
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 32);
});
it('FE-AI-PLACES-006: opening the form waits for nothing: an undecided change and offline leave it on', () => {
it('FE-AI-PLACES-006: waits while a change is undecided, and offline', () => {
const waiting = answer([
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } },
]);
seed({ messages: [waiting] });
const { unmount } = renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
unmount();
seed();
setForcedOffline(true);
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.getByRole('button', { name: 'Add to trip' })).toBeEnabled();
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
});
it('FE-AI-PLACES-007: a suggested place says so, and one the trip has goes to the map instead', () => {
@@ -229,33 +234,15 @@ describe('AssistantPlaceStrip', () => {
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
});
it('FE-AI-PLACES-010: a journey chat lists the places but offers no "Add to trip" while the trip is on screen', () => {
// The journey key never matches the planner's trip, so the trip check already hides "Add to trip";
it('FE-AI-PLACES-010: a journey chat lists the places but offers no "+ Day" while the trip is on screen', () => {
// The journey key never matches the planner's trip, so the trip check already hides "+ Day";
// the scope check in the hook is defence in depth for the day trips and journeys share keys.
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 }, answer(), {
tripId: assistantChatKey({ kind: 'journey', journeyId: TRIP }),
});
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
});
});
describe('"+ Tour" on a found place (P6)', () => {
it('FE-AI-PLACES-011: shows only while a saved tour of this trip is open, and holds the place as its stop', () => {
seed();
const insertFoundPlace = vi.fn(async () => 'held' as const);
useAssistantStore.setState({ insertFoundPlace });
setOpenTour({ tripId: TRIP, placeId: 64, name: 'Herzogstand' });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: 'Add to tour' }));
expect(insertFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 64);
});
it('FE-AI-PLACES-012: a tour open on another trip, or a traveller who may not edit, gets no "+ Tour"', () => {
seed();
setOpenTour({ tripId: TRIP + 1, placeId: 64, name: 'Elsewhere' });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.queryByRole('button', { name: 'Add to tour' })).toBeNull();
expect(screen.queryByRole('button', { name: /^Add to/ })).toBeNull();
expect(screen.queryByRole('button', { name: /Pick a day/ })).toBeNull();
});
});
@@ -1,49 +0,0 @@
/**
* FE-AI-REMEMBER-001 to FE-AI-REMEMBER-002
*
* A note the assistant kept on its own: a quiet line naming it, the trip
* notes read again while the answer is written, and the notes a tap away.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { useSettingsStore } from '../../store/settingsStore';
import AssistantRemembered from './AssistantRemembered';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
const SETTINGS = useSettingsStore.getState();
function renderLine(live: boolean) {
const view: AssistantView = { tripId: 4, variant: 'window', density: 'compact' };
return render(
<AssistantViewContext.Provider value={view}>
<AssistantRemembered note="We eat vegetarian" live={live} />
</AssistantViewContext.Provider>
);
}
afterEach(() => {
useSettingsStore.setState(SETTINGS, true);
vi.clearAllMocks();
});
describe('AssistantRemembered', () => {
it('FE-AI-REMEMBER-001: names the note, and reads the notes again only while the answer is written', () => {
const loadSettings = vi.fn(async () => {});
useSettingsStore.setState({ loadSettings });
const { unmount } = renderLine(false);
expect(screen.getByText('We eat vegetarian')).toBeInTheDocument();
expect(screen.getByText('Noted for this trip:')).toBeInTheDocument();
expect(loadSettings).not.toHaveBeenCalled();
unmount();
renderLine(true);
expect(loadSettings).toHaveBeenCalledTimes(1);
});
it('FE-AI-REMEMBER-002: the pencil opens the trip notes', () => {
useSettingsStore.setState({ loadSettings: vi.fn(async () => {}) });
renderLine(false);
fireEvent.click(screen.getByRole('button', { name: 'Edit trip notes' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
@@ -1,49 +0,0 @@
import { BookmarkCheck, Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { Tooltip } from '../shared/Tooltip';
import AssistantMemoryDialog from './AssistantMemoryDialog';
import { useAssistantScope } from './assistantViewContext';
/**
* A note the assistant kept in the trip notes on its own, as a quiet line in
* its answer, with the notes a tap away to change or drop it. Kept while the
* answer is written, the notes on this device are read again, so the dialog
* never saves over the note with what it held before.
*/
export default function AssistantRemembered({ note, live }: { note: string; live: boolean }) {
const { t } = useTranslation();
const scope = useAssistantScope();
const loadSettings = useSettingsStore((s) => s.loadSettings);
const [editing, setEditing] = useState(false);
useEffect(() => {
if (live) void loadSettings();
}, [live, loadSettings]);
const tripId = scope.kind === 'trip' ? scope.tripId : null;
return (
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-surface-secondary px-2.5 py-1.5 text-caption">
<BookmarkCheck size={13} strokeWidth={2.2} aria-hidden="true" className="flex-none text-success" />
<span className="min-w-0 flex-1">
<span className="font-semibold text-content-secondary">{t('assistant.memory.kept')}</span>{' '}
<bdi className="text-content-muted">{note}</bdi>
</span>
{tripId !== null && (
<Tooltip label={t('assistant.memory.edit')} placement="top">
<button
type="button"
onClick={() => setEditing(true)}
aria-label={t('assistant.memory.edit')}
className="grid h-6 w-6 flex-none place-items-center rounded-md text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
>
<Pencil size={12} aria-hidden="true" />
</button>
</Tooltip>
)}
{editing && tripId !== null && <AssistantMemoryDialog tripId={tripId} onClose={() => setEditing(false)} />}
</div>
);
}
@@ -1,51 +0,0 @@
/**
* FE-AI-SOURCES-001 to FE-AI-SOURCES-002
*
* The pages an answer cites from the provider's web search: a chip per page
* with its site and title, opening in a new tab without telling the page who
* sent the reader, and the block they form in an answer.
*/
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import AssistantSources from './AssistantSources';
import { messageBlocks } from './assistantParts';
describe('AssistantSources', () => {
it('FE-AI-SOURCES-001: names each page by its site and title, and opens it in a new tab', () => {
render(
<AssistantSources
sources={[
{ url: 'https://www.rostock.de/hafen', title: 'Stadthafen Rostock' },
{ url: 'https://example.org/a', title: 'https://example.org/a' },
]}
/>
);
expect(screen.getByRole('navigation', { name: 'Sources' })).toBeInTheDocument();
const link = screen.getByRole('link', { name: /rostock\.de/ });
expect(link).toHaveAttribute('href', 'https://www.rostock.de/hafen');
expect(link).toHaveAttribute('target', '_blank');
expect(link.getAttribute('rel')).toContain('noreferrer');
expect(screen.getByText('Stadthafen Rostock')).toBeInTheDocument();
// A title that is only the address shows the site alone.
expect(screen.getByRole('link', { name: 'example.org' })).toBeInTheDocument();
});
it('FE-AI-SOURCES-002: an answer keeps its sources as a block of their own after its text', () => {
const blocks = messageBlocks(
{
id: 'm1',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-23T10:00:00.000Z',
parts: [
{ type: 'tool-call', toolCallId: 'web-1', toolName: 'web_search', args: { query: 'x' }, result: 'searched' },
{ type: 'text', text: 'Eat at the harbour.' },
{ type: 'data-trek-sources', data: { sources: [{ url: 'https://a.example', title: 'A' }] } },
],
},
false
);
expect(blocks.map((block) => block.kind)).toEqual(['steps', 'text', 'sources']);
});
});
@@ -1,48 +0,0 @@
import type { AssistantSources as Sources } from '@trek/shared';
import { Globe } from 'lucide-react';
import { useTranslation } from '../../i18n';
/** A page's host without "www.", as the chip names it; the whole address where it does not read as one. */
function hostOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
/**
* The pages an answer drew on from the provider's web search, under its text:
* a row of quiet chips with each page's site and title, opening in a new tab.
* No page's icon is loaded, so showing the sources asks no site for anything.
*/
export default function AssistantSources({ sources }: { sources: Sources['sources'] }) {
const { t } = useTranslation();
return (
<nav aria-label={t('assistant.sources.title')} className="flex flex-col gap-1.5">
<span className="font-geist text-caption font-bold uppercase tracking-[.08em] text-content-faint">
{t('assistant.sources.title')}
</span>
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
{sources.map((source) => (
<li key={source.url} className="min-w-0 max-w-full">
<a
href={source.url}
target="_blank"
rel="noopener noreferrer nofollow"
title={source.title}
className="flex min-w-0 max-w-[16rem] items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2.5 py-1 text-caption text-content-secondary no-underline transition-colors hover:bg-surface-hover hover:text-content"
>
<Globe size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
<span className="flex-none font-medium">{hostOf(source.url)}</span>
{source.title && source.title !== source.url && (
<bdi className="min-w-0 truncate text-content-muted">{source.title}</bdi>
)}
</a>
</li>
))}
</ul>
</nav>
);
}
@@ -177,7 +177,7 @@ describe('AssistantSurface', () => {
],
},
},
{ type: 'text', text: 'Rain on Monday, so Nishiki Market is a good pick.' },
{ type: 'text', text: 'Rain on Monday, so the market is a good pick.' },
],
},
]);
@@ -29,7 +29,6 @@ export default function AssistantSurface({
onFocusDay,
onRevealMap,
bookVersion,
onOpenTripChat,
}: AssistantSurfaceProps) {
const runtime = useAssistantRuntime(tripId);
// The phone is always compact; a window is what its width says.
@@ -42,9 +41,8 @@ export default function AssistantSurface({
onFocusDay,
onRevealMap,
bookVersion,
onOpenTripChat,
}),
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion]
);
return (
<AssistantRuntimeProvider runtime={runtime}>
@@ -1,5 +1,4 @@
import { ChevronRight, Luggage } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState';
@@ -7,19 +6,12 @@ import { Spinner } from '../shared/Spinner';
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker';
/**
* The user's trips to go on with in one trip's own chat, from the home chat's
* head: that chat, its history and its changes belong to the trip. The
* running and the next trip lead. The list opens before the trips are there,
* so the focus goes onto the first one once they come.
* Outside a trip the assistant first asks which one: the chat, its history
* and its changes all belong to one trip. The running and the next trip lead.
*/
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
const { t } = useTranslation();
const trips = useAssistantTripPicker();
const first = useRef<HTMLButtonElement>(null);
useEffect(() => {
first.current?.focus({ preventScroll: true });
}, [trips]);
if (trips === null) {
return (
@@ -39,10 +31,9 @@ export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerT
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
<ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0">
{trips.map((trip, i) => (
{trips.map((trip) => (
<li key={trip.id}>
<button
ref={i === 0 ? first : undefined}
type="button"
onClick={() => onPick(trip)}
className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
@@ -28,15 +28,20 @@ export default function AssistantWelcome({
}) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
// The Studio's chat is about a book, not a plan; the home chat about all of the user's travels.
const scope = useAssistantScope().kind;
const book = scope === 'journey';
// The Studio's chat is about a book, not a plan.
const book = useAssistantScope().kind === 'journey';
const hints = useAssistantHints(tripId, { withStays: true });
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} />
<EmptyState
scene="chat"
size={72}
compact
surface="var(--bg-elevated)"
title={book ? t('journey.studio.assistant.welcome') : t('assistant.empty.title')}
/>
{!canEdit && (
<p className="m-0 max-w-xs text-center text-caption text-content-muted">
{book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')}
@@ -48,12 +53,6 @@ export default function AssistantWelcome({
);
}
/** The question over the empty chat, by what the chat is about. */
function welcomeTitle(scope: 'trip' | 'journey' | 'home', t: (key: string) => string): string {
if (scope === 'journey') return t('journey.studio.assistant.welcome');
return scope === 'home' ? t('assistant.home.welcome') : t('assistant.empty.title');
}
export function AssistantSuggestions({
suggestions,
layout,
@@ -1,9 +1,8 @@
/**
* FE-AI-WIN-001 to FE-AI-WIN-016
* FE-AI-WIN-001 to FE-AI-WIN-012
*
* The desktop window around the chat: when it shows at all, the button it
* opens from, the home chat outside a trip with its way into one trip's chat
* and back, closing that keeps the chat, and
* opens from, the trip picker outside a trip, closing that keeps the chat, and
* Escape, which stops an answer before it closes anything (and leaves the
* header's menu to itself), and where the focus goes on the way; a trip's head
* offers the notes its turns read. The geometry is
@@ -37,14 +36,12 @@ vi.mock('./AssistantSurfaceLazy', () => ({
density,
onFocusDay,
onOpen,
onOpenTripChat,
}: {
tripId: number;
tripTitle: string;
density?: string;
onFocusDay?: (dayId: number) => void;
onOpen?: (target: EntityTarget) => void;
onOpenTripChat?: (trip: { id: number; title: string }, question: string | null) => void;
}) => (
<div>
<p>
@@ -69,11 +66,6 @@ vi.mock('./AssistantSurfaceLazy', () => ({
</button>
</>
)}
{onOpenTripChat && (
<button type="button" onClick={() => onOpenTripChat({ id: 9, title: 'Lisbon' }, 'What is missing?')}>
go on in Lisbon
</button>
)}
<textarea aria-label="composer" />
</div>
),
@@ -144,36 +136,12 @@ describe('AssistantWindow', () => {
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-WIN-004: outside a trip it opens on the home chat, whose head opens one trip chat and goes back', async () => {
it('FE-AI-WIN-004: outside a trip it asks which trip first, then chats there', async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />);
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument();
expect(resume).toHaveBeenCalledWith(0);
// The home chat has no trip notes.
expect(screen.queryByRole('button', { name: 'Trip notes' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Chat about one trip' }));
expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back to all trips' }));
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument();
});
it("FE-AI-WIN-013: the home chat's card goes on in the trip's chat with the question waiting there", () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />);
fireEvent.click(screen.getByRole('button', { name: 'go on in Lisbon' }));
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('What is missing?');
// In a trip's chat there is no card to go on from.
expect(screen.queryByRole('button', { name: 'go on in Lisbon' })).not.toBeInTheDocument();
});
it('FE-AI-WIN-014: minimized outside a trip, the round button brings the home chat back', () => {
seed({ open: true, minimized: true });
render(<AssistantWindow trip={null} />);
expect(screen.getByText('chat 0 Your travels')).not.toBeVisible();
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
expect(useAssistantStore.getState().window.minimized).toBe(false);
});
it('FE-AI-WIN-005: closed while writing, the chat stays mounted and the round button breathes until it is opened', () => {
@@ -238,34 +206,6 @@ describe('AssistantWindow', () => {
expect(useAssistantStore.getState().window.open).toBe(true);
});
it("FE-AI-WIN-015: the header's menu says whether it is open, and Escape hands the focus back to its button", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
const more = screen.getByRole('button', { name: 'More options' });
expect(more).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(more);
expect(more).toHaveAttribute('aria-expanded', 'true');
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
expect(more).toHaveFocus();
expect(more).toHaveAttribute('aria-expanded', 'false');
// From the button the next Escape is the window's again.
fireEvent.keyDown(more, { key: 'Escape' });
expect(useAssistantStore.getState().window.open).toBe(false);
});
it("FE-AI-WIN-016: the home chat's trip list takes the focus onto the first trip once the trips are there", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />);
const button = screen.getByRole('button', { name: 'Chat about one trip' });
fireEvent.click(button);
// The list's own row, not the chat's "go on in Lisbon" beside it.
const lisbon = await screen.findByRole('button', { name: /^Lisbon/ });
await waitFor(() => expect(lisbon).toHaveFocus());
fireEvent.keyDown(lisbon, { key: 'Escape' });
expect(button).toHaveFocus();
});
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
seed({ open: true, minimized: false });
const onFocusDay = vi.fn();
@@ -298,10 +238,6 @@ describe('AssistantWindow', () => {
<Location />
</>
);
// From the home chat only another page opens.
fireEvent.click(screen.getByRole('button', { name: 'open page' }));
expect(screen.getByTestId('location')).toHaveTextContent('/atlas');
fireEvent.click(screen.getByRole('button', { name: 'Chat about one trip' }));
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
fireEvent.click(screen.getByRole('button', { name: 'open tab' }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/9?tab=finanzplan&expense=3');
@@ -1,21 +1,20 @@
import './assistant.css';
import { Sparkles, X } from 'lucide-react';
import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import type { WindowRect } from '../../hooks/useDragResize';
import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { useAssistantStore } from '../../store/assistantStore';
import AssistantFrameEdges from './AssistantFrameEdges';
import AssistantHeader from './AssistantHeader';
import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher';
import AssistantSurfaceLazy from './AssistantSurfaceLazy';
import AssistantTripPicker from './AssistantTripPicker';
import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
import { plannerSearchOf } from './assistantShowOnMap';
import type { AssistantChatTrip } from './assistantViewContext';
import type { EntityTarget } from './cards/cardTypes';
import { useAssistantHome } from './useAssistantHome';
import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantThread } from './useAssistantThread';
@@ -31,18 +30,17 @@ import { useAssistantThread } from './useAssistantThread';
* an answer is still being written. Escape stops an answer first, then
* closes, and the focus goes back to where it was.
*
* Outside a trip it opens on the home chat, which knows all of the user's
* trips; its head lists them to go on in one trip's own chat, and a trip's
* chat opened that way has the way back home in its head.
*
* Mounted once for the whole app; it shows nothing until the assistant has a
* connection this user can use.
*/
type WindowTrip = AssistantChatTrip;
interface WindowTrip {
id: number;
title: string;
}
interface AssistantWindowProps {
/** The trip on screen; null outside a trip, where the window opens on the home chat. */
/** The trip on screen; null outside a trip, where the window asks which one first. */
trip: WindowTrip | null;
/** Opens what a card or an applied change points at, from the trip on screen. */
onOpen?: (target: EntityTarget) => void;
@@ -72,16 +70,12 @@ function ChatWindow({
onOpen,
onFocusDay,
onRevealMap,
onOpenTripChat,
tripSwitch,
}: {
trip: WindowTrip;
hidden: boolean;
onOpen?: (target: EntityTarget) => void;
onFocusDay?: (dayId: number) => void;
onRevealMap?: () => void;
onOpenTripChat?: (trip: WindowTrip, question: string | null) => void;
tripSwitch?: Parameters<typeof AssistantHeader>[0]['tripSwitch'];
}) {
const { t } = useTranslation();
const thread = useAssistantThread(trip.id);
@@ -156,7 +150,6 @@ function ChatWindow({
thread={thread}
tripTitle={trip.title}
dragProps={frame.handleProps}
tripSwitch={tripSwitch}
// Closing only hides the window: an answer being written keeps coming in,
// and the floating button says so until it is opened again.
onClose={() => setMinimized(true)}
@@ -169,7 +162,6 @@ function ChatWindow({
onOpen={onOpen}
onFocusDay={onFocusDay}
onRevealMap={onRevealMap}
onOpenTripChat={onOpenTripChat}
/>
</div>
</section>
@@ -185,21 +177,19 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
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]);
useAssistantHotkey(available, toggle);
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
// A trip picked outside the planner is only for this opening; the next one asks again.
if (open !== wasOpen) {
setWasOpen(open);
if (!open) setPicked(null);
}
const current = trip ?? picked ?? home.chat;
const atHome = current.id === HOME_CHAT_KEY;
const current = trip ?? picked;
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
const openPicked = useCallback(
(target: EntityTarget) => {
@@ -208,14 +198,6 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
},
[navigate, picked]
);
// The home chat has no trip to show anything in; another page it simply opens.
const openFromHome = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page') void navigate(target.path);
},
[navigate]
);
const goHome = useCallback(() => setPicked(null), []);
// Another page leaves the trip: the chat stays on it, as if it had been picked.
const openOnScreen = useCallback(
(target: EntityTarget) => {
@@ -227,27 +209,52 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
if (!available) return null;
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
if (!current) return <PickerWindow onPick={setPicked} />;
const onScreen = !!trip && current.id === trip.id;
let openTarget = onScreen ? onOpen && openOnScreen : openPicked;
if (atHome) openTarget = openFromHome;
let tripSwitch: { onPick?: (trip: WindowTrip) => void; onHome?: () => void } | undefined;
if (atHome) tripSwitch = { onPick: setPicked };
else if (!onScreen) tripSwitch = { onHome: goHome };
return (
<>
<ChatWindow
trip={current}
hidden={minimized}
onOpen={openTarget}
onOpen={onScreen ? onOpen && openOnScreen : openPicked}
// Only the planner on screen has days to select and a map to bring back.
onFocusDay={onScreen ? onFocusDay : undefined}
onRevealMap={onScreen ? onRevealMap : undefined}
onOpenTripChat={atHome ? home.openTripChat : undefined}
tripSwitch={tripSwitch}
/>
{/* Hidden while an answer may still come in: the round button brings it back, on any page. */}
{minimized && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} focusOnMount />}
{minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
</>
);
}
function PickerWindow({ onPick }: { onPick: (trip: WindowTrip) => void }) {
const { t } = useTranslation();
const close = useAssistantStore((s) => s.close);
return (
<section
role="dialog"
aria-labelledby="assistant-picker-title"
onKeyDown={(e) => {
if (e.key === 'Escape' && !e.defaultPrevented) close();
}}
className="trek-assistant-pop fixed z-[var(--z-nav)] flex max-h-[min(32rem,calc(100vh-6rem))] w-[22rem] flex-col overflow-hidden rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150"
style={{ insetInlineEnd: LAUNCHER_INSET, bottom: LAUNCHER_INSET, transformOrigin: 'bottom' }}
>
<header className="flex h-10 flex-shrink-0 items-center gap-2 border-b border-edge-faint pe-1.5 ps-3">
<Sparkles size={14} aria-hidden="true" className="flex-shrink-0 text-content-muted" />
<h2 id="assistant-picker-title" className="m-0 flex-1 truncate text-body font-semibold text-content">
{t('assistant.picker.title')}
</h2>
<button
type="button"
onClick={close}
aria-label={t('common.close')}
className="flex h-7 w-7 items-center justify-center rounded-md text-content-muted hover:bg-surface-hover hover:text-content"
>
<X size={16} aria-hidden="true" />
</button>
</header>
<AssistantTripPicker onPick={({ id, title }) => onPick({ id, title })} />
</section>
);
}
+18 -2
View File
@@ -168,6 +168,20 @@
animation: trek-assistant-busy 1.8s ease-in-out infinite;
}
/* The dot beside what the assistant does right now: a soft beat, a halo going out. */
@keyframes trek-assistant-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
}
70%,
100% {
box-shadow: 0 0 0 6px transparent;
}
}
.trek-assistant-pulse {
animation: trek-assistant-pulse 1.4s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.trek-assistant-pop,
.trek-assistant-enter {
@@ -176,7 +190,8 @@
.trek-assistant-settle {
transition: none;
}
.trek-assistant-busy {
.trek-assistant-busy,
.trek-assistant-pulse {
animation: none;
}
.trek-assistant-shimmer {
@@ -196,7 +211,8 @@
[data-reduce-motion] .trek-assistant-settle {
transition: none;
}
[data-reduce-motion] .trek-assistant-busy {
[data-reduce-motion] .trek-assistant-busy,
[data-reduce-motion] .trek-assistant-pulse {
animation: none;
}
[data-reduce-motion] .trek-assistant-shimmer {
@@ -107,7 +107,6 @@ const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
no_tracks_found: { messageKey: 'assistant.error.noTracksFound', action: null },
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
generic: {
messageKey: 'assistant.error.generic',
@@ -225,17 +224,6 @@ const PHASE_SUGGESTIONS: Record<TripPhase, AssistantSuggestion[]> = {
const MAX_SUGGESTIONS = 4;
/**
* What the empty home chat offers, outside any one trip: questions across the
* user's trips and about the parts of TREK that belong to none of them.
*/
export const HOME_SUGGESTIONS: readonly AssistantSuggestion[] = [
{ id: 'homeNext', labelKey: 'assistant.suggest.homeNext' },
{ id: 'homeCompare', labelKey: 'assistant.suggest.homeCompare' },
{ id: 'homeAtlas', labelKey: 'assistant.suggest.homeAtlas' },
{ id: 'homeVacay', labelKey: 'assistant.suggest.homeVacay' },
];
/**
* The questions the empty chat offers: about the place or day in focus first,
* then what fits the trip's phase. The label is also the prompt, so a tapped
@@ -200,7 +200,7 @@ describe('messageBlocks: found places and weather', () => {
read('s2', []),
places('s2', [found('s2:0', { name: 'Place s1:0' }), found('s2:1', { googlePlaceId: 'ChIJa', lat: 40 })], 3),
places('s3', [found('s3:0', { lat: 40 })]),
{ type: 'text', text: 'Three good ones, Place s3:0 first.' },
{ type: 'text', text: 'Three good ones.' },
]),
false
);
@@ -208,66 +208,10 @@ describe('messageBlocks: found places and weather', () => {
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
// The same name at the same spot, or the same Google id, shows once.
expect(blocks[1]).toMatchObject({
found: { toolCallId: 's3', more: 5, places: [{ ref: 's3:0' }, { ref: 's1:0' }, { ref: 's1:1' }] },
found: { toolCallId: 's3', more: 5, places: [{ ref: 's1:0' }, { ref: 's1:1' }, { ref: 's3:0' }] },
});
});
it('FE-AI-PARTS-008d: an answer that planned a tour shows no found places, even one whose planning failed', () => {
const blocks = messageBlocks(
message([
places('s1', [found('s1:0', { name: 'Treseburg' })]),
{
type: 'tool-call',
toolCallId: 'h1',
toolName: 'propose_hike',
args: {},
result: 'Router down',
isError: true,
},
{ type: 'text', text: 'Treseburg is the turning point.' },
]),
false
);
expect(blocks.map((b) => b.kind)).not.toContain('places');
});
it('FE-AI-PARTS-008c: a note kept in the trip notes is a line of its own, a refused or running one a step', () => {
const kept = (id: string, extra: Record<string, unknown>): AssistantMessagePart =>
({
type: 'tool-call',
toolCallId: id,
toolName: 'remember_for_trip',
args: { note: ' Vegetarian ' },
...extra,
}) as AssistantMessagePart;
const blocks = messageBlocks(
message([
kept('m1', { result: { notes: 'Vegetarian' } }),
kept('m2', { result: 'The trip notes are full.', isError: true }),
]),
false
);
expect(blocks).toMatchObject([
{ kind: 'remembered', note: 'Vegetarian' },
{ kind: 'steps', steps: [{ toolCallId: 'm2' }] },
]);
expect(messageBlocks(message([kept('m3', {})]), true)).toMatchObject([{ kind: 'steps' }]);
});
it('FE-AI-PARTS-008b: an answer that names none of the places it found shows no strip', () => {
const blocks = messageBlocks(
message([places('s1', [found('s1:0', { name: 'Café Ñandú' })]), { type: 'text', text: 'It rains all day.' }]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['text']);
// Named with other accents and case, it is the same place.
const named = messageBlocks(
message([places('s1', [found('s1:0', { name: 'Café Ñandú' })]), { type: 'text', text: 'Try cafe nandu.' }]),
false
);
expect(named.map((b) => b.kind)).toEqual(['places', 'text']);
});
it('FE-AI-PARTS-009: drops the places a change of the model proposes, and the strip if none is left', () => {
const blocks = messageBlocks(
message([
@@ -8,7 +8,6 @@ import {
type AssistantMessage,
type AssistantMessagePart,
type AssistantPlaces,
type AssistantSources,
type AssistantWeatherDay,
type TrekCard,
} from '@trek/shared';
@@ -67,10 +66,6 @@ type MessageBlock =
| { kind: 'change'; key: string; items: ChangeItem[] }
| { kind: 'places'; key: string; found: AssistantPlaces }
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] }
// A note the model kept in the trip notes on its own, as a quiet line.
| { kind: 'remembered'; key: string; note: string }
// The pages the answer cites from the provider's web search, under its text.
| { kind: 'sources'; key: string; sources: AssistantSources['sources'] }
| CardsBlock;
/** How cards are laid out: the group each joins, and which ones give way to a change they served. */
@@ -98,77 +93,6 @@ function proposed(place: AssistantFoundPlace, effects: readonly AssistantChangeE
});
}
/** A name as it is compared with the answer's words: lower case, without accents or marks. */
function folded(text: string): string {
return text.normalize('NFKD').replace(/\p{M}/gu, '').toLocaleLowerCase();
}
/**
* The found places the answer names, first, then the rest; null when the
* answer is written and names none of them. A search the answer does not
* draw on is the model's own groundwork and shows no strip.
*/
function namedFirst(places: readonly AssistantFoundPlace[], text: string): AssistantFoundPlace[] | null {
if (text.trim() === '') return [...places];
const words = folded(text);
const named = places.filter((place) => {
const name = folded(place.name).trim();
return name.length >= 3 && words.includes(name);
});
if (named.length === 0) return null;
return [...named, ...places.filter((place) => !named.includes(place))];
}
/**
* The tools that plan or save a tour. An answer that ran one is about the
* tour, planned or not: the places its searches went through to find the
* points are the model's groundwork, not suggestions, and show no strip.
*/
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'propose_hike',
'create_hike',
'propose_tour',
'create_tour',
'create_paddle_tour',
'create_ski_tour',
'update_tour',
'propose_city_walk',
'create_city_walk',
'plan_paddle_route',
'plan_ski_route',
'prepare_tour_change',
'save_tour_change',
'propose_round_hike',
'propose_hike_leg_way',
'import_wanderer_trail',
]);
/** The tool that keeps a note in the trip notes; kept at once, without a card. */
const REMEMBER_TOOL = 'remember_for_trip';
/** The note a call kept, once it is kept; null for any other call, one still running or one refused. */
function rememberedNote(part: Extract<AssistantMessagePart, { type: 'tool-call' }>): string | null {
if (part.toolName !== REMEMBER_TOOL || part.approval || part.result === undefined || part.isError) return null;
const note = (part.args as { note?: unknown }).note;
return typeof note === 'string' && note.trim() !== '' ? note.trim() : null;
}
/** The strip of the answer's found places, named ones first; null when it names none or none is left. */
function placesBlock(
message: AssistantMessage,
effects: readonly AssistantChangeEffect[],
index: number
): MessageBlock | null {
const found = allFound(message.parts);
const text = message.parts.map((p) => (p.type === 'text' ? p.text : '')).join(' ');
const places = namedFirst(
found.places.filter((place) => !proposed(place, effects)),
text
);
if (!places || places.length === 0) return null;
return { kind: 'places', key: `places-${index}`, found: { ...found, places } };
}
/** Every day the answer's weather steps looked up, one entry per date (the later look wins), by date. */
function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDay[] {
const byDate = new Map<string, AssistantWeatherDay>();
@@ -332,9 +256,7 @@ export function messageBlocks(
// An answer about a tour shows the tour, not the places its searches went through to find it.
const changeCards = message.parts.flatMap((p) => (p.type === 'data-trek-change' && p.data.card ? [p.data.card] : []));
const placesHidden =
[...cards.values(), ...changeCards].some((card) => rules.hidesFoundPlaces?.(card) === true) ||
message.parts.some((p) => p.type === 'tool-call' && TOUR_PLANNING_TOOLS.has(p.toolName));
const placesHidden = [...cards.values(), ...changeCards].some((card) => rules.hidesFoundPlaces?.(card) === true);
const blocks: MessageBlock[] = [];
message.parts.forEach((part, index) => {
if (part.type === 'text') {
@@ -342,12 +264,9 @@ export function messageBlocks(
return;
}
if (part.type === 'data-trek-places' && index === lastPlaces && !placesHidden) {
const strip = placesBlock(message, effects, index);
if (strip) blocks.push(strip);
return;
}
if (part.type === 'data-trek-sources') {
blocks.push({ kind: 'sources', key: `sources-${index}`, sources: part.data.sources });
const found = allFound(message.parts);
const places = found.places.filter((place) => !proposed(place, effects));
if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...found, places } });
return;
}
if (part.type === 'data-trek-weather' && index === firstWeather) {
@@ -367,11 +286,6 @@ export function messageBlocks(
pushChange(blocks, item, fromStrip);
return;
}
const note = rememberedNote(part);
if (note !== null) {
blocks.push({ kind: 'remembered', key: `remembered-${part.toolCallId}`, note });
return;
}
const step: ToolStep = {
toolCallId: part.toolCallId,
toolName: part.toolName,
@@ -460,7 +374,4 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
members: { labelKey: 'assistant.change.label.members' },
route: { labelKey: 'assistant.change.label.route' },
difficulty: { labelKey: 'assistant.change.label.difficulty' },
track: { labelKey: 'assistant.change.label.track' },
packing: { labelKey: 'assistant.change.label.packing' },
breaks: { labelKey: 'assistant.change.label.breaks' },
};
@@ -34,17 +34,6 @@ export interface AssistantView {
* version the trial ran on.
*/
bookVersion?: number | null;
/**
* Goes on in a trip's own chat with a question waiting there, from the home
* chat's card that offers it; left out where no chat can be switched to.
*/
onOpenTripChat?: (trip: AssistantChatTrip, question: string | null) => void;
}
/** A trip as the chat switches to it: its id, which is its chat's key, and its title for the head. */
export interface AssistantChatTrip {
id: number;
title: string;
}
export const AssistantViewContext = createContext<AssistantView | null>(null);
@@ -1,5 +1,5 @@
/**
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-017
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-016
*
* The pure side of the book's page card: a page named as the Studio names
* it, a layout and a finding by their words, the findings worth a look, the
@@ -8,7 +8,7 @@
* the card carries it and the book is still at the trial's version), and
* where an applied change opens the Studio.
*/
import { BOOK_FINDING_CODES, blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
import { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
@@ -67,9 +67,6 @@ describe('bookCardModel', () => {
it('FE-AI-BOOKPAGE-011: a layout is named by its template, its grid, its own name or as laid out again', () => {
expect(layoutLabel({ kind: 'template', id: 'two-up', name: null }, t)).toBe('journey.studio.tpl.twoUp');
expect(layoutLabel({ kind: 'template', id: 'cover-full', name: null }, t)).toBe('journey.studio.tpl.coverFull');
expect(layoutLabel({ kind: 'template', id: 'postcard-houses', name: null }, t)).toBe(
'studio.templates.name.postcardHouses'
);
expect(layoutLabel({ kind: 'grid', id: 'two-across', name: null }, t)).toBe('assistant.card.book.grid {"count":2}');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: 'Mine' }, t)).toBe('Mine');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1');
@@ -95,15 +92,6 @@ describe('bookCardModel', () => {
expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi');
});
it('FE-AI-BOOKPAGE-017: every finding the checks answer with has its words, the art checks among them', () => {
for (const code of BOOK_FINDING_CODES) {
expect(findingLabel({ code, severity: 'info' }, t)).toBe(`assistant.card.book.finding.${code}`);
}
for (const code of ['flat_cover', 'weak_hierarchy', 'off_theme', 'energy_mismatch'] as const) {
expect(BOOK_FINDING_CODES).toContain(code);
}
});
it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => {
const doc = book();
const [edit] = pageViewOf(card(), doc, 'pending');
@@ -10,7 +10,6 @@ import {
type BookSpread,
findBookGrid,
findBookTemplate,
findDecoratedTemplate,
setBookCover,
} from '@trek/shared';
@@ -58,15 +57,6 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
font_mix: { labelKey: 'assistant.card.book.finding.font_mix' },
palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' },
repeat_layout: { labelKey: 'assistant.card.book.finding.repeat_layout' },
raster_effect: { labelKey: 'assistant.card.book.finding.raster_effect' },
hairline: { labelKey: 'assistant.card.book.finding.hairline' },
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' },
flat_cover: { labelKey: 'assistant.card.book.finding.flat_cover' },
weak_hierarchy: { labelKey: 'assistant.card.book.finding.weak_hierarchy' },
off_theme: { labelKey: 'assistant.card.book.finding.off_theme' },
energy_mismatch: { labelKey: 'assistant.card.book.finding.energy_mismatch' },
};
/** A page as the Studio names it: the cover, the first page, double page 3. */
@@ -92,7 +82,7 @@ export function layoutLabel(layout: BookCardLayout | null, t: Translate): string
const grid = findBookGrid(layout.id);
return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null;
}
const template = findBookTemplate(layout.id) ?? findDecoratedTemplate(layout.id);
const template = findBookTemplate(layout.id);
return template ? t(template.labelKey) : null;
}
@@ -37,7 +37,6 @@ export const BOOK_LOOK = {
// The server's own read: it waits for the Studio to draw the page, and says it looks at it.
preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
list_book_assets: { stepKey: 'assistant.step.bookAssets' },
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
},
entities: {
@@ -1,4 +1,4 @@
import { BookmarkMinus, BookmarkPlus, Copy, Palette, Ruler, Sparkles } from 'lucide-react';
import { BookmarkMinus, BookmarkPlus, Copy, Ruler, Sparkles } from 'lucide-react';
import type { PackLook } from '../cardTypes';
@@ -19,7 +19,6 @@ export const BOOK_DESIGN_LOOK = {
verbs: {
designBook: { Icon: Sparkles, tone: 'info', labelKey: 'assistant.change.verb.designBook' },
setBookSetup: { Icon: Ruler, tone: 'info', labelKey: 'assistant.change.verb.setBookSetup' },
setBookTheme: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setBookTheme' },
duplicateBookPage: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.duplicateBookPage' },
saveBookLayout: { Icon: BookmarkPlus, tone: 'success', labelKey: 'assistant.change.verb.saveBookLayout' },
removeBookLayout: { Icon: BookmarkMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeBookLayout' },
@@ -24,19 +24,6 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
journal: { labelKey: 'assistant.card.bookDesign.style.journal' },
bold: { labelKey: 'assistant.card.bookDesign.style.bold' },
scrapbook: { labelKey: 'assistant.card.bookDesign.style.scrapbook' },
// A design in one of the themes is called by the theme's own name.
'poster-pop': { labelKey: 'studio.theme.name.posterPop' },
cutout: { labelKey: 'studio.theme.name.cutout' },
postcard: { labelKey: 'studio.theme.name.postcard' },
riviera: { labelKey: 'studio.theme.name.riviera' },
'fabric-classic': { labelKey: 'studio.theme.name.fabricClassic' },
'minimal-serif': { labelKey: 'studio.theme.name.minimalSerif' },
'grid-badge': { labelKey: 'studio.theme.name.gridBadge' },
'scrapbook-script': { labelKey: 'studio.theme.name.scrapbookScript' },
tropical: { labelKey: 'studio.theme.name.tropical' },
nordic: { labelKey: 'studio.theme.name.nordic' },
alpine: { labelKey: 'studio.theme.name.alpine' },
'india-stamp': { labelKey: 'studio.theme.name.indiaStamp' },
};
type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -28,7 +28,6 @@ import { BUDGET_LOOK } from './budget/budgetLook';
import type { CardLook, EntityLook, StepLook, VerbLook } from './cardTypes';
import { COLLAB_LOOK } from './collab/collabLook';
import { COLLECTION_LOOK } from './collection/collectionLook';
import { HOME_LOOK } from './home/homeLook';
import { JOURNEY_LOOK } from './journey/journeyLook';
import { PACKING_LOOK } from './packing/packingLook';
import { ROADTRIP_LOOK } from './roadtrip/roadtripLook';
@@ -130,8 +129,6 @@ export const TOOL_LABELS: Readonly<Partial<Record<string, { labelKey: string }>>
/** How each reading tool is named while it runs; a tool not listed reads "Working on it". */
export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
get_trip_summary: { stepKey: 'assistant.step.reading' },
// The provider's own web search, told as a step of its own (ASSISTANT_WEB_SEARCH_STEP).
web_search: { stepKey: 'assistant.step.webSearch', live: { liveKey: 'assistant.step.webSearchFor', param: 'query' } },
list_places: { stepKey: 'assistant.step.reading' },
search_place: {
stepKey: 'assistant.step.searching',
@@ -171,7 +168,6 @@ export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
...PACKING_LOOK.steps,
...BOOK_LOOK.steps,
...BOOK_DESIGN_LOOK.steps,
...HOME_LOOK.steps,
};
/** Shown on the map by its day: a stop or a note lives on one. */
@@ -236,7 +232,6 @@ export const CARD_LOOKS: { readonly [K in TrekCardKind]: CardLook<K> } = {
...PACKING_LOOK.cards,
...BOOK_LOOK.cards,
...BOOK_DESIGN_LOOK.cards,
...HOME_LOOK.cards,
};
/** The look of the card in hand, typed for any kind. */
@@ -8,7 +8,6 @@ import { BUDGET_VIEWS } from './budget/budgetViews';
import type { CardView } from './cardTypes';
import { COLLAB_VIEWS } from './collab/collabViews';
import { COLLECTION_VIEWS } from './collection/collectionViews';
import { HOME_VIEWS } from './home/homeViews';
import { JOURNEY_VIEWS } from './journey/journeyViews';
import { PACKING_VIEWS } from './packing/packingViews';
import { ROADTRIP_VIEWS } from './roadtrip/roadtripViews';
@@ -34,7 +33,6 @@ export const CARD_VIEWS: { readonly [K in TrekCardKind]: CardView<K> } = {
...PACKING_VIEWS,
...BOOK_VIEWS,
...BOOK_DESIGN_VIEWS,
...HOME_VIEWS,
};
/** The view of the card in hand, typed for any kind. */
@@ -1,85 +0,0 @@
/**
* FE-AI-TRIPCHAT-001 to FE-AI-TRIPCHAT-004
*
* The home chat's card that offers a trip's own chat: the trip under its head
* with its dates, where it stands and its places, the question waiting there,
* and the button that goes on in that chat, shown only where a chat can be
* switched to.
*/
import type { TripChatCard } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
import type { CardEntry } from '../cardTypes';
import { TripChatBlock } from './TripChatBlock';
const CARD: TripChatCard = {
kind: 'tripChat',
trip: { id: 5, title: 'Kyoto', startDate: '2026-11-02', endDate: '2026-11-09', phase: 'next', placeCount: 12 },
question: 'What is still missing?',
};
function entry(card: TripChatCard, toolCallId = 'c1'): CardEntry {
return { toolCallId, card };
}
function show(cards: CardEntry[], view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: 0, variant: 'window', ...view };
return render(
<AssistantViewContext.Provider value={value}>
<TripChatBlock cards={cards} />
</AssistantViewContext.Provider>
);
}
describe('TripChatBlock', () => {
it('FE-AI-TRIPCHAT-001: shows the trip, where it stands, its places and the question', () => {
show([entry(CARD)]);
expect(screen.getByRole('region', { name: 'Continue in a trip chat' })).toBeInTheDocument();
expect(screen.getByText('Kyoto')).toBeInTheDocument();
expect(screen.getByText('Trip chat')).toBeInTheDocument();
// The dates, not the hidden label of the question, which has a "to" of its own.
expect(screen.getByText(/\d to \w/)).toBeInTheDocument();
expect(screen.getByText('Next')).toBeInTheDocument();
expect(screen.getByText('12 places')).toBeInTheDocument();
expect(screen.getByText('What is still missing?')).toBeInTheDocument();
});
it("FE-AI-TRIPCHAT-002: the button goes on in the trip's chat with the question", () => {
const onOpenTripChat = vi.fn();
show([entry(CARD)], { onOpenTripChat });
fireEvent.click(screen.getByRole('button', { name: "Continue in this trip's chat" }));
expect(onOpenTripChat).toHaveBeenCalledWith({ id: 5, title: 'Kyoto' }, 'What is still missing?');
});
it('FE-AI-TRIPCHAT-003: no button where no chat can be switched to, and a trip without dates says so', () => {
const bare: TripChatCard = {
kind: 'tripChat',
trip: { id: 6, title: 'Someday', startDate: null, endDate: null, phase: 'undated', placeCount: null },
question: null,
};
show([entry(bare)]);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByText('No dates yet')).toBeInTheDocument();
expect(screen.queryByText('Next')).not.toBeInTheDocument();
});
it('FE-AI-TRIPCHAT-004: two trips sit in one card, each with its own button; nothing to show draws nothing', () => {
const onOpenTripChat = vi.fn();
const other: TripChatCard = {
...CARD,
trip: { ...CARD.trip, id: 7, title: 'Oslo', phase: 'now', startDate: '2026-10-20', endDate: '2026-10-20' },
question: null,
};
show([entry(CARD), entry(other, 'c2')], { onOpenTripChat, variant: 'fullscreen' });
const buttons = screen.getAllByRole('button', { name: "Continue in this trip's chat" });
expect(buttons).toHaveLength(2);
fireEvent.click(buttons[1]);
expect(onOpenTripChat).toHaveBeenCalledWith({ id: 7, title: 'Oslo' }, null);
expect(screen.getByText('Under way')).toBeInTheDocument();
const { container } = render(<TripChatBlock cards={[]} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -1,114 +0,0 @@
import type { TripChatCard, TripChatPhase } from '@trek/shared';
import { ArrowRight, Luggage, MessageSquareText } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts';
import { useAssistantView } from '../../assistantViewContext';
import type { CardEntry } from '../cardTypes';
import CardFrame from '../parts/CardFrame';
import CardHeader from '../parts/CardHeader';
import { useCardFormat } from '../parts/useCardFormat';
/**
* The home chat's way into one trip's own chat: the trip under its head (its
* dates, where it stands, how many places it holds), the question that waits
* there ready to send, and the button that goes on in that chat. The button
* shows only where a chat can be switched to; the window switches in place,
* the phone opens the trip with its chat.
*/
/** Where the trip stands, as a pill; a trip without dates says so on its line instead. */
const PHASE: Readonly<Record<Exclude<TripChatPhase, 'undated'>, { labelKey: string; tone: PillTone }>> = {
now: { labelKey: 'assistant.card.tripChat.phase.now', tone: 'success' },
next: { labelKey: 'assistant.card.tripChat.phase.next', tone: 'accent' },
upcoming: { labelKey: 'assistant.card.tripChat.phase.upcoming', tone: 'info' },
past: { labelKey: 'assistant.card.tripChat.phase.past', tone: 'neutral' },
};
function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }) {
const { t } = useTranslation();
const { onOpenTripChat, variant } = useAssistantView();
const format = useCardFormat();
const { trip, question } = card;
const start = trip.startDate ?? trip.endDate;
const end = trip.endDate ?? trip.startDate;
let dates = t('assistant.card.tripChat.noDates');
if (start && end) {
dates =
start === end
? format.date(start)
: t('assistant.card.tripChat.dates', { start: format.date(start), end: format.date(end) });
}
const phase = trip.phase === 'undated' ? null : PHASE[trip.phase];
return (
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
{first ? (
<CardHeader
icon={Luggage}
tone="info"
eyebrow={t('assistant.card.tripChat.eyebrow')}
title={trip.title}
meta={dates}
>
<TripFacts phase={phase} placeCount={trip.placeCount} />
</CardHeader>
) : (
<div className="mb-2.5">
<bdi className="block truncate text-body font-bold text-content">{trip.title}</bdi>
<div className="mt-0.5 text-caption text-content-muted">{dates}</div>
<TripFacts phase={phase} placeCount={trip.placeCount} />
</div>
)}
{question && (
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
</p>
)}
{onOpenTripChat && (
<button
type="button"
onClick={() => onOpenTripChat({ id: trip.id, title: trip.title }, question)}
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
variant === 'fullscreen' ? 'h-11' : 'h-9'
}`}
>
{t('assistant.card.tripChat.open')}
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
</button>
)}
</div>
);
}
/** The pills under the trip's name: where it stands and how many places it holds. */
function TripFacts({
phase,
placeCount,
}: {
phase: { labelKey: string; tone: PillTone } | null;
placeCount: number | null;
}) {
const { t } = useTranslation();
if (!phase && !placeCount) return null;
return (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{phase && <SoftPill tone={phase.tone}>{t(phase.labelKey)}</SoftPill>}
{!!placeCount && <WhiteBadge>{t('collections.placeCount', { count: placeCount })}</WhiteBadge>}
</div>
);
}
export function TripChatBlock({ cards }: { cards: readonly CardEntry[] }) {
const { t } = useTranslation();
const trips = cards.flatMap((entry) => (entry.card.kind === 'tripChat' ? [entry.card] : []));
if (trips.length === 0) return null;
return (
<CardFrame label={t('assistant.card.tripChat.label')}>
{trips.map((card, index) => (
<TripChatSection key={`${card.trip.id}-${index}`} card={card} first={index === 0} />
))}
</CardFrame>
);
}
@@ -1,21 +0,0 @@
import type { PackLook } from '../cardTypes';
/**
* What the home chat (the assistant outside any one trip) brings to the
* chat's pure look: the words its reads run under and the look of the card
* that offers a trip's own chat. It has no verbs or entities of its own; the
* changes it proposes are the atlas', the vacation planner's and the
* collections'. A look stays pure, literals only.
*/
export const HOME_LOOK = {
verbs: {},
tools: {},
steps: {
list_trips: { stepKey: 'assistant.step.readingTrips' },
open_trip_chat: { stepKey: 'assistant.step.openTripChat' },
},
entities: {},
cards: {
tripChat: {},
},
} satisfies PackLook<'tripChat'>;
@@ -1,12 +0,0 @@
import type { CardView } from '../cardTypes';
import { TripChatBlock } from './TripChatBlock';
/**
* How the home chat's kind draws: the trip it offers to go on with, beside
* the answer. This file only maps the kind to its view; the component lives
* in a sibling file, since a file that exports anything besides components
* breaks fast refresh.
*/
export const HOME_VIEWS = {
tripChat: { Block: TripChatBlock },
} satisfies { [K in 'tripChat']: CardView<K> };
@@ -20,16 +20,7 @@ import { ROW_GLYPH, type RowChange } from './checklistModel';
* list, and the rows of a run of changes with their include choice.
*/
/** The band behind a group's head: its colour faint over the card's grey, the grey alone without one. */
function bandOf(color: string): string {
return color ? `color-mix(in srgb, ${color} 12%, var(--bg-secondary))` : 'var(--bg-secondary)';
}
/**
* A group's head as a section of the list: a full width band in its colour,
* the colour dot, its name, and at the end its badge (how many it holds) and
* who looks after it, the ones a change drops faded.
*/
/** A group's head: its colour, its name, a badge, and who looks after it (the ones a change drops faded). */
export function ChecklistHead({
color,
name,
@@ -47,17 +38,14 @@ export function ChecklistHead({
lead?: ReactNode;
}) {
return (
<div
className="mt-1.5 flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5"
style={{ background: bandOf(color) }}
>
<div className="flex min-w-0 items-center gap-2 pt-1">
{lead ?? <CategoryDot color={color} />}
<bdi title={name} className="min-w-0 flex-1 truncate text-caption font-semibold text-content">
<bdi title={name} className="min-w-0 truncate text-caption font-semibold text-content">
{name}
</bdi>
{(badge || people.length > 0 || removed.length > 0) && (
<span className="flex flex-none items-center gap-1.5">
{badge}
{badge}
{(people.length > 0 || removed.length > 0) && (
<span className="ms-auto flex flex-none items-center gap-1.5">
<PeopleStack people={people} />
{/* The people a change takes off, faded; the card says "No longer" in words under the head. */}
{removed.length > 0 && (
@@ -58,13 +58,13 @@ function BagSwatch({ bag, size = 'row' }: { bag: PackingCardBag | undefined; siz
/** A row's quantity and weight: quiet figures at its end, no frame each. */
const QUIET = 'text-caption tabular-nums text-content-muted';
/** How many rows a group holds, a white pill at the end of its band. */
/** How many rows a group holds, quiet beside its name. */
function GroupCount({ count }: { count: number }) {
const { t } = useTranslation();
return (
<span
aria-label={t('tours.packing.items', { count })}
className="inline-flex h-[18px] flex-none items-center rounded-full bg-surface-card px-1.5 text-caption font-semibold tabular-nums text-content-secondary shadow-sm"
className="flex-none text-caption tabular-nums text-content-faint"
>
{count}
</span>
@@ -231,7 +231,7 @@ function BagHead({
}) {
const { t } = useTranslation();
if (!bag) {
const badge = <GroupCount count={group.count} />;
const badge = inChange ? undefined : <GroupCount count={group.count} />;
const lead = <BagSwatch bag={undefined} size="head" />;
return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />;
}
@@ -298,7 +298,7 @@ export function PackingGroup({
<ChecklistHead
color={packingColorOf(key.slot, name)}
name={name}
badge={<GroupCount count={group.count} />}
badge={inChange ? undefined : <GroupCount count={group.count} />}
people={group.assignees}
removed={group.assigneesRemoved}
/>
@@ -48,14 +48,14 @@ export default function CardHeader({
children?: ReactNode;
}) {
return (
<div className="-mx-3.5 -mt-3 mb-3 px-3.5 pb-2.5 pt-2.5" style={{ background: tint }}>
<div className={`flex gap-2.5 ${meta ? 'items-start' : 'items-center'}`}>
<div className="-mx-3.5 -mt-3 mb-3 px-3.5 pb-3 pt-3" style={{ background: tint }}>
<div className={`flex gap-3 ${meta ? 'items-start' : 'items-center'}`}>
{tile ?? (
<span
aria-hidden="true"
className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm ${GLYPH[tone]}`}
className={`grid h-10 w-10 flex-none place-items-center rounded-[12px] bg-surface-card shadow-sm ${GLYPH[tone]}`}
>
<Icon size={15} strokeWidth={2} />
<Icon size={18} strokeWidth={2} />
</span>
)}
<div className="min-w-0 flex-1">
@@ -71,14 +71,14 @@ export default function CardHeader({
<bdi
title={title}
className="line-clamp-2 min-w-0 font-bold leading-tight tracking-[-0.01em] text-content"
style={fs(13.5, 'body')}
style={fs(15, 'body')}
>
{title}
</bdi>
{count !== undefined && (
<span
className="inline-flex h-[18px] flex-none items-center rounded-full bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary shadow-sm"
style={fs(10.5)}
className="inline-flex h-5 flex-none items-center rounded-full bg-surface-card px-2 font-semibold tabular-nums text-content-secondary shadow-sm"
style={fs(11)}
>
{count}
</span>
@@ -5,7 +5,6 @@ import { useTranslation } from '../../../../i18n';
import { useSettingsStore } from '../../../../store/settingsStore';
import { formatClockTime, formatDate, formatMoney } from '../../../../utils/formatters';
import { formatDistance, formatElevation } from '../../../../utils/units';
import { formatRunPace, runPaceUnitText } from '../../../Tours/run/runPace';
/**
* How a card writes its numbers, the way the rest of TREK does for this
@@ -14,7 +13,7 @@ import { formatRunPace, runPaceUnitText } from '../../../Tours/run/runPace';
* units. A card hands these to its words as `{value}`, never a raw number.
*/
export function useCardFormat() {
const { locale, t } = useTranslation();
const { locale } = useTranslation();
const twelveHour = useSettingsStore((s) => s.settings.time_format === '12h');
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
return useMemo(() => {
@@ -38,8 +37,6 @@ export function useCardFormat() {
return m === 0 ? hours.format(h) : `${hours.format(h)} ${mins.format(m)}`;
},
count: (value: number) => number.format(value),
/** "5:30 min/km" from seconds per kilometre, per mile with distances in miles. */
pace: (secondsPerKm: number) => `${formatRunPace(secondsPerKm, unit)} ${runPaceUnitText(unit, t)}`,
};
}, [locale, twelveHour, unit, t]);
}, [locale, twelveHour, unit]);
}
@@ -1,5 +1,5 @@
/**
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042, FE-AI-TOUR-061 to FE-AI-TOUR-062
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042
*
* A route in the chat as the Tours tab shows a hike: its figures and grade,
* a profile only where the heights are known, the profile, the stop chips
@@ -387,44 +387,4 @@ describe('TourRouteCard', () => {
expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
expect(screen.getAllByText('Start by 10:30')).toHaveLength(1);
});
it('FE-AI-TOUR-061: a bike tour names its profile and its riding time, a run its pace, and waits for one it lacks', () => {
const bike: Route = {
...HIKE,
activity: 'bike',
routingProfile: 'gravel',
minutesBasis: 'router',
limit: null,
grades: null,
};
const { container } = renderCard(bike);
expect(screen.getByText('Gravel')).toBeInTheDocument();
expect(screen.queryByTestId('tour-grade')).toBeNull();
expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled();
expect(container.querySelector('[data-field="pace"]')).toBeNull();
const run: Route = {
...bike,
activity: 'run',
routingProfile: 'trail',
minutesBasis: 'pace',
paceSecondsPerKm: 330,
};
const paced = renderCard(run);
expect(screen.getByText('Trail running')).toBeInTheDocument();
expect(paced.container.querySelector('[data-field="pace"]')).toHaveTextContent('5:30 min/km');
renderCard({ ...run, paceSecondsPerKm: null, minutes: null });
expect(screen.getByText('Tell the assistant your pace to save this run.')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Plan this' });
expect(buttons[buttons.length - 1]).toBeDisabled();
});
it('FE-AI-TOUR-062: a whole paddle plan offers "Plan this"; one with a leg that failed does not', () => {
const plan: Route = { ...HIKE, activity: 'kayak', source: 'plan', limit: null, grades: null, schedule: null };
renderCard(plan);
expect(screen.getByRole('button', { name: 'Plan this' })).toBeInTheDocument();
renderCard({ ...plan, name: 'Broken', warnings: [{ kind: 'legs_failed', at: null, count: 1 }] });
expect(screen.getAllByRole('button', { name: 'Plan this' })).toHaveLength(1);
});
});
@@ -16,7 +16,7 @@ import RouteSketch from '../parts/RouteSketch';
import { useCardFormat } from '../parts/useCardFormat';
import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel';
import { PROFILE_WORDS, routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
import { routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
/**
* The head of a route card: the route drawn small beside its name, the
@@ -63,16 +63,14 @@ export function TourRouteFigures({ card }: { card: TourRouteCard }) {
/**
* The grade of a hike, as the hike's own card shows it: the hardest SAC step
* along the way, uncertain when too little of it is graded, with what it
* means, the routing limit and where grades come from in its tooltip. A bike
* tour or a run names its profile instead, any other tour what kind it is.
* means, the routing limit and where grades come from in its tooltip. Any
* other tour says what kind it is instead.
*/
export function TourGrade({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
if (card.activity !== 'hike' || !card.grades) {
const icon = createElement(tourDayRowIcon(card.activity), { size: 10, strokeWidth: 2.2, 'aria-hidden': true });
// A bike tour or a run says what it was planned for: "Gravel", "Trail running".
const words = card.routingProfile ? PROFILE_WORDS[card.routingProfile] : TOUR_TYPE_WORDS[card.activity];
return <SoftPill icon={icon}>{t(words.labelKey)}</SoftPill>;
return <SoftPill icon={icon}>{t(TOUR_TYPE_WORDS[card.activity].labelKey)}</SoftPill>;
}
const { highest, unknownShare } = card.grades;
const uncertain = unknownShare >= 0.5;
@@ -140,7 +138,7 @@ export function TourRouteSketch({
export function TourRouteFields({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
const format = useCardFormat();
const fields: Array<{ key: string; label: string; value: string }> = factKeysOf(card).flatMap((key) => {
const fields = factKeysOf(card).flatMap((key) => {
const value = factValue(card, key);
if (value === null) return [];
// A field's label is short; how the time was reckoned is the figure badges' tooltip.
@@ -150,10 +148,6 @@ export function TourRouteFields({ card }: { card: TourRouteCard }) {
else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
return [{ key, label, value: text }];
});
// A run's time is its distance at the runner's pace: the pace stands beside it.
if (card.paceSecondsPerKm != null) {
fields.push({ key: 'pace', label: t('tours.run.pace'), value: format.pace(card.paceSecondsPerKm) });
}
return <CardFields fields={fields} />;
}
@@ -166,7 +160,7 @@ export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; o
const { t } = useTranslation();
const name = routeName(card, t);
const tile = (
<span className="w-12 flex-none overflow-hidden rounded-[10px] bg-surface-card p-1 shadow-sm">
<span className="w-14 flex-none overflow-hidden rounded-[12px] bg-surface-card p-1 shadow-sm">
<TourRouteSketch card={card} height={48} onShow={onShow} />
</span>
);
@@ -179,9 +173,9 @@ export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; o
title={name}
meta={card.label ?? undefined}
>
{((card.activity === 'hike' && card.grades) || card.routingProfile || card.schedule) && (
{((card.activity === 'hike' && card.grades) || card.schedule) && (
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
{((card.activity === 'hike' && card.grades) || card.routingProfile) && <TourGrade card={card} />}
{card.activity === 'hike' && card.grades && <TourGrade card={card} />}
<DayPill card={card} />
</div>
)}
@@ -1,5 +1,5 @@
/**
* FE-AI-TOUR-033 to FE-AI-TOUR-034, FE-AI-TOUR-063
* FE-AI-TOUR-033 to FE-AI-TOUR-034
*
* A held tour change in its row: the route drawn small with its figures and
* grade and a small profile, the kind of tour as the row's tile; a tour whose
@@ -71,10 +71,4 @@ describe('TourRouteInRow', () => {
expect(delta).toHaveTextContent('+1.6 km');
expect(delta).toHaveTextContent('+20 min');
});
it('FE-AI-TOUR-063: a held bike tour or run names its profile in its row, never a hike grade', () => {
renderRow({ ...PENDING, activity: 'bike', routingProfile: 'road', grades: null, limit: null });
expect(screen.getByText('Road')).toBeInTheDocument();
expect(screen.queryByTestId('tour-grade')).toBeNull();
});
});
@@ -53,7 +53,7 @@ export default function TourRouteInRow({ card, item }: { card: TourRouteCard; it
<span className="min-w-0 flex-1">
<WayFigures way={legVariantOf(card, 'current')} unit={unit} />
</span>
{((grades && grades.highest !== null) || card.routingProfile) && <TourGrade card={card} />}
{grades && grades.highest !== null && <TourGrade card={card} />}
</span>
{previous && (
<VariantDelta
@@ -18,7 +18,6 @@ import CardHeader from '../parts/CardHeader';
import { CardSection } from '../parts/CardSection';
import WayFigures from './TourFigureLine';
import { TourRouteBody } from './TourRouteCard';
import { TourGrade } from './TourRouteHead';
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords';
import { useLineHover, useTourRouteCard } from './useTourRouteCard';
@@ -115,8 +114,6 @@ function WayOption({
{grades && grades.highest !== null && (
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />
)}
{/* Ways of a ride or a run may differ by profile: each says its own. */}
{entry.card.routingProfile && <TourGrade card={entry.card} />}
</span>
<WayFigures way={way} unit={unit} />
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />}
@@ -1,23 +1,5 @@
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
import {
ArrowLeftRight,
ChartNoAxesColumn,
Circle,
CircleCheck,
CircleSlash,
Copy,
Footprints,
History,
Luggage,
MapPinMinus,
MapPinPlus,
Palette,
Repeat,
Route,
Snowflake,
TriangleAlert,
Waves,
} from 'lucide-react';
import { Route } from 'lucide-react';
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
import type { EntityTarget, PackLook } from '../cardTypes';
@@ -104,45 +86,16 @@ export const TOUR_LOOK = {
verbs: {
addTour: { Icon: Route, tone: 'success', labelKey: 'assistant.change.verb.addTour' },
updateTour: { Icon: Route, tone: 'info', labelKey: 'assistant.change.verb.updateTour' },
markVisited: { Icon: CircleCheck, tone: 'success', labelKey: 'assistant.change.verb.markVisited' },
markSkipped: { Icon: CircleSlash, tone: 'info', labelKey: 'assistant.change.verb.markSkipped' },
markPlanned: { Icon: Circle, tone: 'info', labelKey: 'assistant.change.verb.markPlanned' },
assignTrack: { Icon: Footprints, tone: 'success', labelKey: 'assistant.change.verb.assignTrack' },
unassignTrack: { Icon: Footprints, tone: 'danger', labelKey: 'assistant.change.verb.unassignTrack' },
excludeTour: { Icon: ChartNoAxesColumn, tone: 'info', labelKey: 'assistant.change.verb.excludeTour' },
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' },
reverseTour: { Icon: ArrowLeftRight, tone: 'info', labelKey: 'assistant.change.verb.reverseTour' },
closeLoop: { Icon: Repeat, tone: 'info', labelKey: 'assistant.change.verb.closeLoop' },
copyTour: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.copyTour' },
setRouteColor: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setRouteColor' },
classifySki: { Icon: Snowflake, tone: 'info', labelKey: 'assistant.change.verb.classifySki' },
setPaddlePlan: { Icon: Waves, tone: 'info', labelKey: 'assistant.change.verb.setPaddlePlan' },
decideObstacles: { Icon: TriangleAlert, tone: 'info', labelKey: 'assistant.change.verb.decideObstacles' },
restoreTrack: { Icon: History, tone: 'info', labelKey: 'assistant.change.verb.restoreTrack' },
addTourStop: { Icon: MapPinPlus, tone: 'success', labelKey: 'assistant.change.verb.addTourStop' },
removeTourStop: { Icon: MapPinMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeTourStop' },
},
tools: {},
steps: {
propose_city_walk: { stepKey: 'assistant.step.planningWalk' },
propose_tour: { stepKey: 'assistant.step.planningRoute' },
plan_paddle_route: { stepKey: 'assistant.step.planningRoute' },
plan_ski_route: { stepKey: 'assistant.step.planningRoute' },
get_tour_plan: { stepKey: 'assistant.step.readingTour' },
list_tours: { stepKey: 'assistant.step.readingTours' },
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' },
get_tour_trail_details: { stepKey: 'assistant.step.readingTour' },
propose_round_hike: { stepKey: 'assistant.step.findingRounds' },
propose_hike_leg_way: { stepKey: 'assistant.step.findingWays' },
search_wanderer_trails: { stepKey: 'assistant.step.searchingWanderer' },
preview_wanderer_trail: { stepKey: 'assistant.step.readingTrail' },
check_tour_source: { stepKey: 'assistant.step.checkingSource' },
// P7: a route file attached in the chat, read by the server into its tracks.
read_tour_file: { stepKey: 'assistant.step.readingTourFile' },
},
entities: {
tour: {
@@ -29,7 +29,6 @@ import {
sacAt,
sacPaint,
steepStretches,
takeWaitsKey,
wayGroups,
} from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel';
@@ -137,33 +136,13 @@ describe('tourRouteModel', () => {
});
});
it('FE-AI-TOUR-009: "Plan this" takes a planned way of every type, a city walk, a whole paddle or ski plan', () => {
it('FE-AI-TOUR-009: "Plan this" takes a planned hike or a city walk, nothing else', () => {
expect(canTake(HIKE)).toBe(true);
expect(canTake({ ...HIKE, routed: false })).toBe(false);
expect(canTake({ ...HIKE, activity: 'walk', routed: false })).toBe(true);
expect(canTake({ ...HIKE, activity: 'bike', routingProfile: 'gravel' })).toBe(true);
expect(canTake({ ...HIKE, activity: 'run', routingProfile: 'trail', routed: false })).toBe(false);
expect(canTake(SAVED)).toBe(false);
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(true);
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan' })).toBe(true);
// A plan with a leg that could not be planned is no tour yet; a hike is never a plan.
const failed = [{ kind: 'legs_failed' as const, at: null, count: 1 }];
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan', warnings: failed })).toBe(false);
expect(canTake({ ...HIKE, source: 'plan' })).toBe(false);
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(false);
expect(canTake({ ...HIKE, source: 'pending' })).toBe(false);
// Another way for a leg of a saved hike changes that hike; it is not created anew.
expect(canTake({ ...HIKE, placeId: 12 })).toBe(false);
});
it("FE-AI-TOUR-060: a run waits for the runner's pace; a bike tour, a hike and a run with a pace wait for nothing", () => {
expect(takeWaitsKey({ ...HIKE, activity: 'run', routingProfile: 'running' })).toBe('assistant.card.tour.needsPace');
expect(takeWaitsKey({ ...HIKE, activity: 'run', paceSecondsPerKm: 330 })).toBeNull();
expect(takeWaitsKey({ ...HIKE, activity: 'bike' })).toBeNull();
expect(takeWaitsKey(HIKE)).toBeNull();
// A ride's router time is its riding time; a run's time is at its pace.
expect(timeLabelKey({ activity: 'bike', minutesBasis: 'router' })).toBe('tours.bike.timeEstimated');
expect(timeLabelKey({ activity: 'run', minutesBasis: 'pace' })).toBe('tours.run.timeEstimated');
expect(timeLabelKey({ activity: 'hike', minutesBasis: 'router' })).toBe('tours.scope.routed');
});
it('FE-AI-TOUR-010: proposals of one answer form ways of three at most; anything else stands alone', () => {
@@ -226,31 +226,14 @@ export function routePoints(card: TourRouteCard): [number, number][] {
}
/**
* Whether "Plan this" can take the proposal: a city walk, a hike, a bike
* tour or a run whose way was planned, or a paddle or ski plan whose every
* leg was planned. A saved tour, a held change and the other way of a
* saved hike's leg (named by its place, changed with update_tour rather
* than created anew) cannot.
* Whether "Plan this" can take the proposal: a hike whose way was planned,
* or a city walk. A paddle or ski plan, a saved tour and a held change cannot.
*/
export function canTake(card: TourRouteCard): boolean {
if (card.source === 'plan') {
const whole = card.warnings.every((warning) => warning.kind !== 'legs_failed');
return (card.activity === 'kayak' || card.activity === 'ski') && card.routed && whole;
}
if (card.source !== 'proposal' || card.placeId !== null) return false;
return card.activity === 'walk' || (LAND_PLANS.has(card.activity) && card.routed);
if (card.source !== 'proposal') return false;
return card.activity === 'walk' || (card.activity === 'hike' && card.routed);
}
/** The proposals on land "Plan this" takes once their way is planned. */
const LAND_PLANS: ReadonlySet<TourRouteCard['activity']> = new Set(['hike', 'bike', 'run']);
/** Why "Plan this" waits on a card it can take: a run is saved with the runner's pace only. Null when nothing is missing. */
export function takeWaitsKey(card: TourRouteCard): string | null {
return card.activity === 'run' && card.paceSecondsPerKm == null ? NEEDS_PACE.labelKey : null;
}
const NEEDS_PACE = { labelKey: 'assistant.card.tour.needsPace' } as const;
/** A share as words take it, "40 %". */
export function percentText(share: number): string {
return `${Math.round(share * 100)} %`;
@@ -92,25 +92,10 @@ const TIME_WORDS = {
planned: { labelKey: 'tours.scope.planned' },
stations: { labelKey: 'tours.scope.plannedWithStays' },
piste: { labelKey: 'tours.ski.time' },
pace: { labelKey: 'tours.run.timeEstimated' },
} as const satisfies Record<NonNullable<TourRouteCard['minutesBasis']>, { labelKey: string }>;
/** What each bike and run profile is called, as the Tours tab names it. */
export const PROFILE_WORDS = {
touring: { labelKey: 'tours.bike.profile.touring' },
road: { labelKey: 'tours.bike.profile.road' },
gravel: { labelKey: 'tours.bike.profile.gravel' },
mtb: { labelKey: 'tours.bike.profile.mtb' },
running: { labelKey: 'tours.run.profile.running' },
trail: { labelKey: 'tours.run.profile.trail' },
} as const satisfies Record<NonNullable<TourRouteCard['routingProfile']>, { labelKey: string }>;
/**
* The name of a route's time: by how it was reckoned (a bike tour's router
* time is its riding time), else a paddle's own, else a walking time.
*/
/** The name of a route's time: by how it was reckoned, else a paddle's own, else a walking time. */
export function timeLabelKey(card: Pick<TourRouteCard, 'minutesBasis' | 'activity'>): string {
if (card.minutesBasis === 'router' && card.activity === 'bike') return 'tours.bike.timeEstimated';
if (card.minutesBasis) return TIME_WORDS[card.minutesBasis].labelKey;
return card.activity === 'kayak' ? 'tours.paddle.timeEstimated' : 'tours.planner.walkingEstimated';
}
@@ -11,7 +11,7 @@ import { hasUndecided } from '../../assistantParts';
import { useAssistantView } from '../../assistantViewContext';
import { useChangeHover } from '../../useChangeHover';
import { tourTarget } from './tourLook';
import { canTake, routePoints, takeWaitsKey } from './tourRouteModel';
import { canTake, routePoints } from './tourRouteModel';
import { takeInThread, takeOfProposal } from './tourTakeModel';
/**
@@ -104,8 +104,7 @@ function stateAfter(outcome: HoldOutcome, error: string | null): TakeState {
}
/**
* "Plan this" on a proposed tour (a hike, a bike tour, a run, a city walk)
* or a paddle or ski plan: one tap adds the tour. The
* "Plan this" on a proposed hike or city walk: one tap adds the tour. The
* server holds the tour it creates as a change, without a model turn, and
* the tap decides it at once, since tapping "Plan this" already is the
* decision; the assistant then answers to what was added. Once the tour is
@@ -149,10 +148,9 @@ export function useTakeProposal(toolCallId: string, card: TourRouteCard) {
}, []);
const visible = canTake(card) && !!trip && can('place_edit', trip);
let reasonKey: string | null;
let reasonKey: string | null = null;
if (offline) reasonKey = 'assistant.error.offline';
else if (waiting && state !== 'taken') reasonKey = 'assistant.composer.decideFirst';
else reasonKey = takeWaitsKey(card);
const disabled = reasonKey !== null || isRunning || threadId === null || state === 'busy';
const take = useCallback(async () => {
@@ -1,4 +1,4 @@
import { ASSISTANT_TOUR_FILE_EXTENSIONS, LLM_PHOTO_EXTENSIONS } from '@trek/shared';
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
import { type DragEvent, useState } from 'react';
import { useTranslation } from '../../i18n';
@@ -6,7 +6,7 @@ import { assistantScopeOf } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useToast } from '../shared/Toast';
/** What the booking import reads and the route files taken in as tours (the server checks again), and how big one file may be. */
/** What the booking import reads (the server checks again), and how big one file may be. */
const ATTACH_EXTENSIONS: readonly string[] = [
'.pdf',
'.eml',
@@ -16,7 +16,6 @@ const ATTACH_EXTENSIONS: readonly string[] = [
'.htm',
'.txt',
...LLM_PHOTO_EXTENSIONS,
...ASSISTANT_TOUR_FILE_EXTENSIONS,
];
const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
@@ -30,7 +29,7 @@ function hasFiles(e: DragEvent): boolean {
}
/**
* Hands a booking file or a route file (GPX, KML, KMZ) to the chat, from the paperclip, a paste or a drop on
* Hands a booking file to the chat, from the paperclip, a paste or a drop on
* the window. One file at a time, refused here with a toast when it is not
* one the import reads or too big, so a wrong file never costs a round trip.
* The drop handlers go on the whole chat; `dragging` lights its drop zone.
@@ -1,102 +0,0 @@
/**
* FE-AI-HOME-001 to FE-AI-HOME-004
*
* The home chat's shared hooks: its key and name, the way into one trip's own
* chat with the question waiting there (a question already typed there
* stays), the way in from the pages outside a trip, and the questions the
* empty home chat offers.
*/
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { HOME_SUGGESTIONS } from './assistantModel';
import { useAssistantHome, useAssistantHomeLauncher } from './useAssistantHome';
import { useAssistantSuggestions } from './useAssistantSuggestions';
const INITIAL = useAssistantStore.getState();
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
});
function Home({ switchTo }: { switchTo: (trip: { id: number; title: string }) => void }) {
const home = useAssistantHome(switchTo);
return (
<div>
<p>
chat {home.chat.id} {home.chat.title}
</p>
<button type="button" onClick={() => home.openTripChat({ id: 9, title: 'Lisbon' }, 'What is missing?')}>
ask
</button>
<button type="button" onClick={() => home.openTripChat({ id: 9, title: 'Lisbon' }, null)}>
go
</button>
</div>
);
}
function Launcher() {
const { available, open } = useAssistantHomeLauncher();
return available ? (
<button type="button" onClick={open}>
open
</button>
) : null;
}
function Suggestions() {
const { suggestions, canEdit } = useAssistantSuggestions(0);
return (
<ul data-can-edit={String(canEdit)}>
{suggestions.map((s) => (
<li key={s.id}>{s.prompt}</li>
))}
</ul>
);
}
describe('useAssistantHome', () => {
it('FE-AI-HOME-001: names the home chat and keeps it under its own key', () => {
render(<Home switchTo={vi.fn()} />);
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument();
});
it("FE-AI-HOME-002: goes on in a trip's chat with the question waiting, and never over one typed there", () => {
const switchTo = vi.fn();
render(<Home switchTo={switchTo} />);
fireEvent.click(screen.getByRole('button', { name: 'ask' }));
expect(switchTo).toHaveBeenCalledWith({ id: 9, title: 'Lisbon' });
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('What is missing?');
useAssistantStore.getState().setDraft(9, 'My own words');
fireEvent.click(screen.getByRole('button', { name: 'ask' }));
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('My own words');
fireEvent.click(screen.getByRole('button', { name: 'go' }));
expect(switchTo).toHaveBeenCalledTimes(3);
});
});
describe('useAssistantHomeLauncher', () => {
it('FE-AI-HOME-003: offers the way in only while the assistant can run, and opens the window', () => {
const { rerender } = render(<Launcher />);
expect(screen.queryByRole('button', { name: 'open' })).not.toBeInTheDocument();
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
rerender(<Launcher />);
fireEvent.click(screen.getByRole('button', { name: 'open' }));
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
});
});
describe('the empty home chat', () => {
it('FE-AI-HOME-004: offers questions across the trips, all of them, and holds nothing back', () => {
render(<Suggestions />);
const items = screen.getAllByRole('listitem').map((item) => item.textContent);
expect(items).toHaveLength(HOME_SUGGESTIONS.length);
expect(items).toContain('Which trip is next, and what is still open?');
expect(items).toContain('How many countries have I visited?');
expect(screen.getByRole('list')).toHaveAttribute('data-can-edit', 'true');
});
});
@@ -1,44 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { useAssistantStore } from '../../store/assistantStore';
import type { AssistantChatTrip } from './assistantViewContext';
/**
* The home chat, the assistant outside any one trip, as the desktop window
* and the phone screen both show it: the key its conversations are kept
* under, the name its head carries, and the way into one trip's own chat.
* That way leaves the question the home chat handed over waiting in the
* trip's composer, ready to send, and then lets the view switch (the window
* shows that trip's chat in place, the phone opens the trip with its chat).
*/
export function useAssistantHome(switchTo: (trip: AssistantChatTrip) => void) {
const { t } = useTranslation();
const setDraft = useAssistantStore((s) => s.setDraft);
const openTripChat = useCallback(
(trip: AssistantChatTrip, question: string | null) => {
// A question typed there already stays; the handed-over one only fills an empty composer.
const typed = useAssistantStore.getState().byTrip[trip.id]?.draft ?? '';
if (question && !typed.trim()) setDraft(trip.id, question);
switchTo(trip);
},
[setDraft, switchTo]
);
const title = t('assistant.home.title');
const chat = useMemo((): AssistantChatTrip => ({ id: HOME_CHAT_KEY, title }), [title]);
return { chat, openTripChat };
}
/**
* The way into the home chat on the pages outside a trip (the phone's
* dashboard head and the dock's More list): offered only while the assistant
* can run for this user, like every other way in.
*/
export function useAssistantHomeLauncher() {
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.open);
return { available, open };
}
@@ -1,19 +1,38 @@
import { type AssistantFoundPlace, categoryIdForKind } from '@trek/shared';
import { useCallback } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useOpenTourStore } from '../../store/openTourStore';
import { useCanDo } from '../../store/permissionsStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import type { Assignment } from '../../types';
import { dayLabel } from '../../utils/dayLabel';
import { haversineKm } from '../../utils/geo';
import { safeHexColor } from '../../utils/safeColor';
import { formatDistance } from '../../utils/units';
import { hasUndecided } from './assistantParts';
import { useAssistantScope, useAssistantView } from './assistantViewContext';
import { useCardPoint } from './cards/parts/useCardPoint';
/** A day a found place can be suggested for, named the way the day list names it. */
interface StripDay {
id: number;
/** Its place in the trip, 1 for the first day: "+ Day 3". */
n: number;
label: string;
/** Its weekday, short ("Sat"), for the day grid; null for a day without a date. */
weekday: string | null;
}
/** The short weekday of a `YYYY-MM-DD` date, read as that calendar day wherever the reader is. */
function weekdayOf(date: string | null | undefined, locale: string): string | null {
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
return new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`));
}
/** The trip category a found place most likely belongs in, for its line and its icon. */
interface StripCategory {
name: string;
@@ -35,21 +54,20 @@ function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng:
/**
* What the strip of found places needs, the same for the window and the
* phone: the day in focus, how far a place is from that day's last stop, its
* likely category, whether a place can be added, and the map's part (the pin
* while the pointer rests on a card, the camera on a click).
* phone: the trip's days to suggest a place for, the day in focus, how far a
* place is from that day's last stop, its likely category, whether a
* suggestion can be made now and why not, and the map's part (the pin while
* the pointer rests on a card, the camera on a click).
*
* Adding opens the planner's own place form, filled in with the place and on
* the day in focus: the traveller sees everything about it and saves it as
* any other place, so nothing waits for the chat's open decisions.
*
* P6: while a saved hike or city walk is open in the planner, "+ Tour" takes
* a place into it as a stop: held as a change in the chat like any other,
* so the stop is only saved once the traveller applies it.
* Suggesting writes nothing: the server holds the place as a change card to
* decide like any other. It waits while another change is undecided, because
* one decision answers all open cards.
*/
export function useAssistantPlaceStrip() {
const { tripId, onRevealMap } = useAssistantView();
const { t, locale } = useTranslation();
const { tripId } = useAssistantView();
const scope = useAssistantScope();
const { offline } = useNetworkMode();
const can = useCanDo();
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
const { trip, days, assignments, categories } = useTripStore(
@@ -63,17 +81,44 @@ export function useAssistantPlaceStrip() {
};
})
);
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft);
const insertFoundPlace = useAssistantStore((s) => s.insertFoundPlace);
const openTour = useOpenTourStore((s) => (s.open?.tripId === tripId ? s.open : null));
const { focusDayId, isRunning, threadId, waiting } = useAssistantStore(
useShallow((s) => {
const chat = chatOf(s, tripId);
return {
focusDayId: chat.focus?.dayId ?? null,
isRunning: chat.isRunning,
threadId: chat.threadId,
waiting: hasUndecided(chat.messages),
};
})
);
const addFoundPlace = useAssistantStore((s) => s.addFoundPlace);
const point = useCardPoint();
const [adding, setAdding] = useState<string | null>(null);
const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null;
const stripDays = useMemo(
(): StripDay[] =>
(days ?? []).map((day, index) => ({
id: day.id,
n: index + 1,
label: dayLabel(day, index, t, locale),
weekday: weekdayOf(day.date, locale),
})),
[days, t, locale]
);
const focusDay = stripDays.find((d) => d.id === focusDayId) ?? null;
const from = focusDay && assignments ? lastSpot(assignments[String(focusDay.id)]) : null;
/** Whether "Add to trip" shows at all: a trip's chat (a journey's has no days), the planner holds the trip and the user may add to it. */
const canAdd = scope.kind === 'trip' && !!trip && can('place_edit', trip);
/**
* Whether "+ Day" shows at all: a trip's chat (a journey's has no days), the
* planner holds the trip and the user may add to it.
*/
const canSuggest = scope.kind === 'trip' && !!trip && can('place_edit', trip) && stripDays.length > 0;
/** Why "+ Day" is off right now, as a tooltip; null when it is on. */
let blockedKey: string | null = null;
if (offline) blockedKey = 'assistant.error.offline';
else if (waiting) blockedKey = 'assistant.composer.decideFirst';
const disabled = blockedKey !== null || isRunning || threadId === null || adding !== null;
const categoryOf = (place: AssistantFoundPlace): StripCategory | null => {
const id = categoryIdForKind(place.kind, categories);
@@ -85,19 +130,14 @@ export function useAssistantPlaceStrip() {
const distanceOf = (place: AssistantFoundPlace): string | null =>
from ? formatDistance(haversineKm(from, place), unit) : null;
/** Opens the place form with it; on the phone the chat steps aside first. */
const open = (place: AssistantFoundPlace) => {
if (!canAdd || !trip) return;
onRevealMap?.();
openPlaceDraft(trip.id, place, focusDay?.id ?? null);
};
/** The saved tour a place can be taken into as a stop, null while none is open or nothing can be added. */
const tour = canAdd ? openTour : null;
/** Holds the place as a stop of the open tour, to decide in the chat. */
const addToTour = (place: AssistantFoundPlace) => {
if (tour) void insertFoundPlace(tripId, place.ref, tour.placeId);
const suggest = async (place: AssistantFoundPlace, dayId: number) => {
if (disabled) return;
setAdding(place.ref);
try {
await addFoundPlace(tripId, place.ref, dayId);
} finally {
setAdding(null);
}
};
/** Shows the place on the map; on the phone the chat steps aside first. */
@@ -111,5 +151,17 @@ export function useAssistantPlaceStrip() {
[hoverPoint]
);
return { canAdd, categoryOf, distanceOf, open, show, hover, tour, addToTour };
return {
days: stripDays,
focusDay,
canSuggest,
disabled,
blockedKey,
adding,
categoryOf,
distanceOf,
suggest,
show,
hover,
};
}
@@ -6,7 +6,7 @@ import { useJourneyStore } from '../../store/journeyStore';
import { useCanDo } from '../../store/permissionsStore';
import { useTripStore } from '../../store/tripStore';
import { localIsoDate } from '../../utils/localDate';
import { HOME_SUGGESTIONS, suggestionsFor, tripPhase } from './assistantModel';
import { suggestionsFor, tripPhase } from './assistantModel';
import { useAssistantFocus } from './useAssistantFocus';
export interface ReadySuggestion {
@@ -21,9 +21,7 @@ export interface ReadySuggestion {
* (one picked from the dashboard) neither its dates nor its rights are at
* hand, so it gets the general questions and none that would change it. A
* journey's chat in TREK Studio gets none, since they all ask about a trip,
* and its rights are the journey's role, as the Studio itself reads it. The
* home chat outside any one trip gets its own questions across the user's
* trips; what it may change the server decides, so nothing is held back here.
* and its rights are the journey's role, as the Studio itself reads it.
*/
export function useAssistantSuggestions(tripId: number): { suggestions: ReadySuggestion[]; canEdit: boolean } {
const { t } = useTranslation();
@@ -37,11 +35,9 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug
const journeyRole = useJourneyStore((s) =>
scope.kind === 'journey' && s.current?.id === scope.journeyId ? s.current.my_role : undefined
);
const home = scope.kind === 'home';
const canEdit = tripChat ? !!trip && can('place_edit', trip) : home || (journeyRole ?? 'owner') !== 'viewer';
const canEdit = tripChat ? !!trip && can('place_edit', trip) : (journeyRole ?? 'owner') !== 'viewer';
const suggestions = useMemo((): ReadySuggestion[] => {
if (home) return HOME_SUGGESTIONS.map((s) => ({ id: s.id, prompt: t(s.labelKey) }));
if (!tripChat) return [];
const phase = trip
? tripPhase({
@@ -57,7 +53,7 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug
place: kind === 'place' ? label : null,
day: kind === 'day' ? label : null,
}).map((s) => ({ id: s.id, prompt: t(s.labelKey, s.params) }));
}, [home, tripChat, trip, hasPlaces, canEdit, kind, label, t]);
}, [tripChat, trip, hasPlaces, canEdit, kind, label, t]);
return { suggestions, canEdit };
}
@@ -1,4 +1,4 @@
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-027
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-021
// Plyr needs a real media pipeline, so the player is stubbed here.
vi.mock('./VideoPlayer', () => ({
@@ -17,7 +17,6 @@ vi.mock('../../api/websocket', () => ({
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import PhotoLightbox from './PhotoLightbox';
const samplePhotos = [
@@ -248,82 +247,4 @@ describe('PhotoLightbox', () => {
expect(img.style.transform).toContain('scale(2)');
expect(onClose).not.toHaveBeenCalled();
});
it('FE-COMP-LIGHTBOX-022: is a labelled modal dialog', () => {
render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} label="Show photos" />);
const dialog = screen.getByRole('dialog', { name: 'Show photos' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
});
it('FE-COMP-LIGHTBOX-023: takes the focus when it opens and gives it back to the opener when it closes', () => {
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
try {
const { unmount } = render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} label="Gallery" />);
expect(screen.getByRole('dialog', { name: 'Gallery' })).toHaveFocus();
unmount();
expect(opener).toHaveFocus();
} finally {
opener.remove();
}
});
it('FE-COMP-LIGHTBOX-024: names its arrows', () => {
render(<PhotoLightbox photos={samplePhotos} startIndex={1} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Next photo' }));
expect(screen.getByText('3 / 3')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Previous photo' }));
expect(screen.getByText('2 / 3')).toBeInTheDocument();
});
describe('right to left', () => {
beforeEach(() => {
// The direction follows the language, as in the app, where the provider also sets <html dir>.
useSettingsStore.setState((state) => ({ settings: { ...state.settings, language: 'ar' } }));
});
afterEach(() => {
document.documentElement.dir = '';
});
it('FE-COMP-LIGHTBOX-025: the arrow keys follow the reading direction', () => {
render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} />);
fireEvent.keyDown(window, { key: 'ArrowLeft' });
expect(screen.getByText('2 / 3')).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-026: a swipe to the right turns to the next photo', () => {
const { container } = render(<PhotoLightbox photos={samplePhotos} startIndex={1} onClose={vi.fn()} />);
const surface = container.firstElementChild as HTMLElement;
fireEvent.touchStart(surface, { touches: [{ clientX: 100, clientY: 200 }] });
fireEvent.touchEnd(surface, { changedTouches: [{ clientX: 200, clientY: 210 }] });
expect(screen.getByText('3 / 3')).toBeInTheDocument();
fireEvent.touchStart(surface, { touches: [{ clientX: 200, clientY: 200 }] });
fireEvent.touchEnd(surface, { changedTouches: [{ clientX: 100, clientY: 210 }] });
expect(screen.getByText('2 / 3')).toBeInTheDocument();
});
});
it('FE-COMP-LIGHTBOX-027: a caption with a source page links to it in a new tab, one without stays text', () => {
render(
<PhotoLightbox
photos={[
{ id: 'a', src: '/a.jpg', caption: 'Alice, CC BY 4.0', captionHref: 'https://commons.wikimedia.org/wiki/File:A.jpg' },
{ id: 'b', src: '/b.jpg', caption: 'Google', captionHref: null },
]}
onClose={vi.fn()}
/>,
);
const link = screen.getByRole('link', { name: 'Alice, CC BY 4.0' });
expect(link).toHaveAttribute('href', 'https://commons.wikimedia.org/wiki/File:A.jpg');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByText('Google', { selector: 'p' })).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});
+17 -44
View File
@@ -3,14 +3,12 @@ import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from 'lucide-react'
import VideoPlayer from './VideoPlayerLazy'
import { usePhotoZoom } from './usePhotoZoom'
import { Tooltip } from '../shared/Tooltip'
import { isRtlLanguage, useTranslation } from '../../i18n'
import { useTranslation } from '../../i18n'
interface LightboxPhoto {
id: string
src: string
caption?: string | null
/** Where the caption's credit points to (a photo's source page), opened in a new tab. */
captionHref?: string | null
provider?: string
asset_id?: string | null
owner_id?: number | null
@@ -21,8 +19,6 @@ interface Props {
photos: LightboxPhoto[]
startIndex?: number
onClose: () => void
/** What the dialog is called for assistive tech. */
label?: string
}
/** The round glass buttons of the top bar. */
@@ -32,21 +28,10 @@ const ROUND_BTN: React.CSSProperties = {
color: '#fff', cursor: 'pointer',
}
export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }: Props) {
export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
const [idx, setIdx] = useState(startIndex)
const touchStart = useRef<{ x: number; y: number } | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const { t, language } = useTranslation()
// Right to left, the next photo lies to the left: the arrow keys and swipes follow the reading direction.
// Taken from the language, not read off <html dir>, so it stays put across re-renders.
const rtl = isRtlLanguage(language)
// Focus moves into the dialog when it opens and back to what opened it when it closes.
useEffect(() => {
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
dialogRef.current?.focus()
return () => opener?.focus()
}, [])
const { t } = useTranslation()
const photo = photos[idx]
// Zoom starts over on every photo (#1484).
@@ -61,12 +46,12 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') (rtl ? next : prev)()
if (e.key === 'ArrowRight') (rtl ? prev : next)()
if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') next()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [prev, next, onClose, rtl])
}, [prev, next, onClose])
// A pinch, a double tap or a pan of an enlarged photo belongs to the zoom; only
// the rest is a swipe that turns the page or closes the lightbox.
@@ -94,7 +79,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
}
// horizontal swipe
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
if (dx < 0 !== rtl) next()
if (dx < 0) next()
else prev()
}
touchStart.current = null
@@ -104,13 +89,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
style={{
outline: 'none',
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column',
@@ -165,15 +144,15 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
{hasPrev && (
<button type="button" onClick={prev} aria-label={t('journey.lightbox.previous')} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 focus-visible:opacity-100 transition-opacity" style={{
position: 'absolute', insetInlineStart: 16, zIndex: 5,
<button type="button" onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', left: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronLeft size={22} className="rtl:rotate-180" />
<ChevronLeft size={22} />
</button>
)}
@@ -200,33 +179,27 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
{/* Next button */}
{hasNext && (
<button type="button" onClick={next} aria-label={t('journey.lightbox.next')} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 focus-visible:opacity-100 transition-opacity" style={{
position: 'absolute', insetInlineEnd: 16, zIndex: 5,
<button type="button" onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', right: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronRight size={22} className="rtl:rotate-180" />
<ChevronRight size={22} />
</button>
)}
{/* Caption — bottom center overlay */}
{photo.caption && (
<div style={{ position: 'absolute', bottom: 20, insetInline: 0, zIndex: 5, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
<p style={{
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontStyle: 'italic',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5, maxWidth: '70%', textAlign: 'center',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
padding: '6px 14px', borderRadius: 10, pointerEvents: 'auto',
}}>
{photo.captionHref ? (
<a href={photo.captionHref} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }} className="hover:underline">
{photo.caption}
</a>
) : photo.caption}
</p>
padding: '6px 14px', borderRadius: 10,
}}>{photo.caption}</p>
</div>
)}
</div>
@@ -15,6 +15,8 @@ import {
calculateAlternatives,
avoidedClasses,
routeEngineFor,
sameRoad,
furthestFrom,
osrmLegSegment,
} from './RouteCalculator'
@@ -1202,6 +1204,37 @@ describe('a trip that avoids something', () => {
expect(plain.avoidance).toBeUndefined()
})
it('FE-COMP-ROUTECALCULATOR-064: a road that runs the whole of another and turns off to a point and back is not that road', () => {
// The shape of a leg bent by a via: every point of the plain road lies on it, so
// asked only one way round the two read as one.
const plainRoad = { coordinates: [[61, 10], [61, 11], [61, 12]] as [number, number][], distance: 100_000, duration: 3600 }
const spur = { coordinates: [[61, 10], [61, 11], [61.3, 11], [61, 11], [61, 12]] as [number, number][], distance: 166_000, duration: 5400 }
expect(sameRoad(plainRoad, spur)).toBe(false)
expect(sameRoad(spur, plainRoad)).toBe(false)
expect(sameRoad(plainRoad, { ...plainRoad, distance: 104_000, duration: 3900 })).toBe(true)
})
it('FE-COMP-ROUTECALCULATOR-065: one road drawn with a different spacing of points is still one road', () => {
// Two engines' lines of one long road share almost no vertex. Measured to the nearest
// sampled vertex, a point on the same road sat half a sample off it: kilometres here.
// The dense line is sampled every tenth vertex, 0.05 degrees apart; the sparse one sits
// exactly between those samples, 1.8 km from the nearest, on the very same road.
const dense = Array.from({ length: 2001 }, (_, i) => [50, i * 0.005] as [number, number])
const sparse: [number, number][] = [
[50, 0],
...Array.from({ length: 200 }, (_, i) => [50, 0.025 + i * 0.05] as [number, number]),
[50, 10],
]
const a = { coordinates: dense, distance: 700_000, duration: 25_000 }
const b = { coordinates: sparse, distance: 712_000, duration: 27_000 }
expect(sameRoad(a, b)).toBe(true)
const off = furthestFrom([[50.02, 5]], dense)
expect(off?.index).toBe(0)
expect(off?.km).toBeCloseTo(2.22, 1)
})
it('FE-COMP-ROUTECALCULATOR-066: the avoidance offer beside an OSRM leg says when it crosses by ferry', async () => {
server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 63], [11, 63], [12, 63]] }, distance: 100000, duration: 3600 }] })),
+120 -1
View File
@@ -5,7 +5,7 @@ import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units'
import { countRoute } from './routeUsageCounter'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
import { furthestFrom, sameRoad, type RouteUsageSurface } from '@trek/shared'
import type { RouteUsageSurface } from '@trek/shared'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
@@ -803,6 +803,41 @@ export type RoadLine = Pick<RouteAlternative, 'coordinates' | 'distance' | 'dura
*/
const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const
/**
* How far two lines may stray from each other and still be the same road, in kilometres.
*
* Parallel carriageways, a different slip road onto the same motorway and the noise
* between two engines' idea of where a road is all sit well under this. A way somebody
* would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to
* Copenhagen, which is a genuinely different road, peaks 2.3 km off the default.
*/
const SAME_ROAD_KM = 0.5
/**
* Two lines are the same road.
*
* Length and duration used to decide this, and stopped being enough when a second engine
* started answering. Valhalla prices roads differently: measured over twelve European
* legs its distances ran 1.8 % above OSRM's, so on a 470 km leg the same road comes back
* thirteen kilometres apart and a 200 m tolerance calls it a discovery. Two identical
* blue lines on the map is exactly what that looks like. Duration is the worse of the
* two to lean on: over the same twelve legs it ranged from 14.7 % under OSRM's to 13.2 %
* over, with the sign set by the region rather than by the road.
*
* The geometry does not care which engine drew it, so it decides whenever there is one.
* The numbers stay as the fallback for a line with no shape to compare.
*
* Measured both ways round. Asked only whether `b` stays near `a`, a road that runs the
* whole of `b` and then turns off to a point and back passed as `b`, because every point
* of `b` is on it; that is exactly the shape of a leg bent by a via, and the picker and
* the check behind a choice both have to tell it from the plain road.
*/
export function sameRoad(a: RoadLine, b: RoadLine): boolean {
const there = furthestFrom(b.coordinates, a.coordinates)
const back = furthestFrom(a.coordinates, b.coordinates)
if (there && back) return Math.max(there.km, back.km) < SAME_ROAD_KM
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
}
/**
* Hosts that have already answered "I do not do exclude", by base URL.
@@ -1068,3 +1103,87 @@ function furthestPointFrom(line: [number, number][], reference: [number, number]
const furthest = furthestFrom(line, reference)
return furthest ? { lat: furthest.lat, lng: furthest.lng } : null
}
/**
* The same point, plus how far off it actually is.
*
* The search itself stays in squared degrees with a cosine on the longitude: it only has
* to ORDER the candidates, and a haversine per pair of a 200-by-200 comparison buys
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
* real distance rather than a rank, and half a degree is a different number of metres in
* Andalusia than in Lapland.
*
* `index` is where on `line` the point sits, so points taken off one line can be put back
* in the order that line drives through them.
*/
export function furthestFrom(
line: [number, number][],
reference: [number, number][],
): { index: number; lat: number; lng: number; km: number } | null {
if (!line.length || !reference.length) return null
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
// full cross product of two thousand-point lines is not worth the milliseconds.
const step = Math.max(1, Math.floor(reference.length / 200))
let bestIndex = -1
let bestNeighbour: [number, number] | null = null
let bestDist = -1
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
const near = nearestOn(line[i], reference, step)
if (near.d > bestDist) { bestDist = near.d; bestIndex = i; bestNeighbour = near.at }
}
if (bestIndex < 0) return null
const [lat, lng] = line[bestIndex]
const km = bestNeighbour ? haversineKm({ lat, lng }, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
return { index: bestIndex, lat, lng, km }
}
/**
* Where `reference` passes closest to `point`, as a squared distance in the same scaled
* degrees and the spot on the line it was measured to.
*
* Coarse, then fine. The sampled vertices find the stretch, and the segments either side
* of the nearest one find the road itself. Measured to the sampled vertex alone, a point
* on the very same road sat up to half a sample apart from it, which on a thousand
* kilometre leg is kilometres: two engines' lines of one motorway then read as two roads.
*/
function nearestOn(
point: [number, number],
reference: [number, number][],
step: number,
): { d: number; at: [number, number] } {
const cos = Math.cos((point[0] * Math.PI) / 180)
const squared = (p: [number, number]): number => {
const dLat = point[0] - p[0]
const dLng = (point[1] - p[1]) * cos
return dLat * dLat + dLng * dLng
}
let nearestIndex = 0
let nearest = Infinity
for (let j = 0; j < reference.length; j += step) {
const d = squared(reference[j])
if (d < nearest) { nearest = d; nearestIndex = j }
}
let at = reference[nearestIndex]
const last = Math.min(reference.length - 1, nearestIndex + step)
for (let k = Math.max(0, nearestIndex - step); k < last; k++) {
const on = closestOnSegment(point, reference[k], reference[k + 1], cos)
const d = squared(on)
if (d < nearest) { nearest = d; at = on }
}
return { d: nearest, at }
}
/** The point of segment a-b closest to `p`, with longitude scaled by `cos` so the plane is fair. */
function closestOnSegment(
p: [number, number],
a: [number, number],
b: [number, number],
cos: number,
): [number, number] {
const dx = (b[1] - a[1]) * cos
const dy = b[0] - a[0]
const length = dx * dx + dy * dy
if (length === 0) return a
const t = Math.max(0, Math.min(1, (((p[1] - a[1]) * cos) * dx + (p[0] - a[0]) * dy) / length))
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
}
@@ -1,4 +1,4 @@
// FE-PACKPOP-001 to FE-PACKPOP-009
// FE-PACKPOP-001 to FE-PACKPOP-008
import { describe, it, expect, vi } from 'vitest'
import { useRef } from 'react'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
@@ -56,15 +56,6 @@ describe('PopoverItem', () => {
expect(danger.style.background).toBe('none')
})
it('FE-PACKPOP-009: the entry open now is said to be the current one, not only tinted', () => {
render(<>
<PopoverItem icon={null} label="Temples" active current onClick={() => {}} />
<PopoverItem icon={null} label="Food" onClick={() => {}} />
</>)
expect(screen.getByRole('button', { name: 'Temples' })).toHaveAttribute('aria-current', 'true')
expect(screen.getByRole('button', { name: 'Food' })).not.toHaveAttribute('aria-current')
})
it('FE-PACKPOP-004: the composer button fades while there is nothing to add', () => {
expect(composerConfirm(false)).toMatchObject({ opacity: 0.35, cursor: 'default' })
expect(composerConfirm(true)).toMatchObject({ opacity: 1, cursor: 'pointer' })
@@ -7,8 +7,6 @@ interface PopoverItemProps {
label: string
onClick: () => void
active?: boolean
/** The entry is the one open now (a chat in a history list), said so to a screen reader too. */
current?: boolean
danger?: boolean
/** A secondary line, such as Add bag, in the faint text colour. */
muted?: boolean
@@ -16,12 +14,11 @@ interface PopoverItemProps {
trailing?: ReactNode
}
export function PopoverItem({ icon, label, onClick, active = false, current = false, danger = false, muted = false, trailing }: PopoverItemProps) {
export function PopoverItem({ icon, label, onClick, active = false, danger = false, muted = false, trailing }: PopoverItemProps) {
const tone = danger ? '#ef4444' : muted ? 'var(--text-faint)' : 'var(--text-primary)'
return (
<button type="button"
onClick={onClick}
aria-current={current || undefined}
style={{
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
padding: '8px 10px', borderRadius: 9, border: 'none', cursor: 'pointer',
@@ -110,8 +110,8 @@ describe('PlaceDetailsColumn', () => {
})
})
it('FE-PDC-004: renders author and licence under the Commons picture in play', async () => {
renderColumn({ selectedImageUrl: COMMONS_PHOTO.url })
it('FE-PDC-004: renders author and licence under every Commons picture', async () => {
renderColumn()
const credit = await screen.findByText('Alice')
// Links to the file description page, where the full terms are.
@@ -122,11 +122,9 @@ describe('PlaceDetailsColumn', () => {
it('FE-PDC-005: names the source when a picture has no author, instead of inventing one', async () => {
placeEnrichment.mockResolvedValue({ photos: [GOOGLE_PHOTO], description: null, facts: [] })
renderColumn({ selectedImageUrl: GOOGLE_PHOTO.url })
renderColumn()
expect(await screen.findByText('Google')).toBeInTheDocument()
// The tile's name carries the credit too, after a colon.
expect(screen.getByRole('button', { name: 'places.details.pickImage: Google' })).toBeInTheDocument()
})
it('FE-PDC-006: picking a picture reports its proxy URL', async () => {
@@ -613,24 +611,17 @@ describe('PlaceDetailsColumn — picture layout', () => {
withPhotos(3)
const { onPickImage } = renderColumn()
// Nothing is picked and there is no own picture to remove, so 'No picture'
// stays away and the first photo is credited.
await screen.findByText('Author 0')
expect(screen.queryByText('places.image.none')).not.toBeInTheDocument()
const tiles = screen.getAllByRole('button', { name: /places.details.pickImage/ })
fireEvent.mouseEnter(tiles[2])
expect(screen.getByText('Author 2')).toBeInTheDocument()
expect(screen.queryByText('Author 0')).not.toBeInTheDocument()
expect(onPickImage).not.toHaveBeenCalled()
fireEvent.mouseLeave(tiles[2])
expect(screen.queryByText('Author 2')).not.toBeInTheDocument()
expect(screen.getByText('Author 0')).toBeInTheDocument()
})
it('FE-PDC-037: credits the picture in play, licence included', async () => {
withPhotos(2)
renderColumn({ selectedImageUrl: '/api/maps/place-photo/p0/bytes' })
renderColumn()
expect(await screen.findByText('Author 0')).toBeInTheDocument()
expect(screen.getByText('Author 0').closest('a')).toHaveAttribute(
@@ -681,12 +672,9 @@ describe('PlaceDetailsColumn — dialog variant', () => {
const tile = await screen.findByRole('button', { name: /places.details.pickImage/ })
expect(tile).not.toHaveAttribute('title')
// Hovering the tile moves the credit line to it.
fireEvent.mouseEnter(tile)
const licence = screen.getByText('CC BY-SA 4.0', { selector: 'a[href*="creativecommons"]' })
const line = licence.parentElement?.parentElement as HTMLElement
expect(line).toHaveTextContent('Alice')
expect(line.textContent).not.toContain('·')
expect(licence.closest('p')).toHaveTextContent('Alice')
expect(licence.closest('p')?.textContent).not.toContain('·')
fireEvent.click(tile)
expect(onPickImage).toHaveBeenCalledWith(COMMONS_PHOTO.url)
@@ -800,41 +788,3 @@ describe('PlaceDetailsColumn — dialog variant', () => {
expect(await screen.findByText('places.details.error', undefined, { timeout: 4000 })).toBeInTheDocument()
})
})
/**
* FE-PDC-050..051 — the editor's own pictures beside the sources' ones.
*/
describe('PlaceDetailsColumn — the place\'s own pictures', () => {
const own = (over: Record<string, unknown> = {}) => ({
currentUrl: '/uploads/places/own.jpg',
attached: [],
pendingPreview: null,
canUpload: true,
onUpload: vi.fn(),
onPickAttached: vi.fn(),
onRemove: vi.fn(),
...over,
})
it('FE-PDC-050: offers the upload and the current picture even while nothing is selected', () => {
const mine = own()
renderColumn({ selection: null, image: { own: mine, chosen: { kind: 'current' } } })
expect(screen.getByText('places.details.pickImage')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'places.uploadImage' })).toBeInTheDocument()
const current = screen.getByRole('button', { name: 'places.image.current' })
expect(current).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(current)
expect(mine.onRemove).toHaveBeenCalledTimes(1)
})
it('FE-PDC-051: the sources\' photos join the own ones once they arrive, the choice following the image prop', async () => {
const { onPickImage } = renderColumn({ image: { own: own(), chosen: { kind: 'url', url: COMMONS_PHOTO.url } } })
const tile = await screen.findByRole('button', { name: 'places.details.pickImage: Alice' })
expect(tile).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'places.image.current' })).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(tile)
expect(onPickImage).toHaveBeenCalledWith(null)
})
})
@@ -4,6 +4,7 @@ import {
ArrowRight,
Bike,
Building2,
Check,
ChefHat,
ChevronDown,
ChevronUp,
@@ -32,22 +33,22 @@ import { fs } from '../shared/DialogShell'
import { LABEL } from '../shared/dialogParts'
import { COLUMN_CARD, ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from './placeDialogParts'
import type { TranslationFn } from '../../types'
import PlaceImagePicker, { type PlaceImageOwn } from './PlaceImagePicker'
import { chosenImage, offersPictures, type ChosenImage } from './placeImageChoice'
import { cacheKeyFor, readCachedEnrichment, storeEnrichment, type PlaceDetailsSelection } from './placeEnrichment'
export { __clearEnrichmentCacheForTests, type PlaceDetailsSelection } from './placeEnrichment'
/** The place the column is describing. Null while nothing is selected. */
export interface PlaceDetailsSelection {
placeId?: string
lat: number
lng: number
name: string
/** The picked search result, so the server can skip its own details lookup. */
details?: Record<string, unknown>
}
interface PlaceDetailsColumnProps {
selection: PlaceDetailsSelection | null
/** Currently chosen hero image, so the picked tile can show as picked. */
selectedImageUrl?: string | null
selectedImageUrl?: string
onPickImage: (url: string | null) => void
/**
* The editor's own pictures (upload, the current one, attached images) and
* which tile is chosen; without it only the sources' pictures are offered.
*/
image?: { own: PlaceImageOwn; chosen: ChosenImage }
onAdoptDescription: (text: string) => void
/** The looked-up opening hours, once they arrive, so the form can take them over (#2472). */
onHours?: (hours: PlaceHours) => void
@@ -74,6 +75,47 @@ interface PlaceDetailsColumnProps {
t: TranslationFn
}
/**
* Module-level cache plus sessionStorage, same shape as usePlaceDetails in
* PlaceInspector. Clicking back and forth between two search results must not
* pay for the provider fan-out twice.
*/
const enrichmentCache = new Map<string, MapsPlaceEnrichmentResult>()
/** Test seam: the module-level cache otherwise leaks between cases. */
export function __clearEnrichmentCacheForTests(): void {
enrichmentCache.clear()
}
function readSession(key: string): MapsPlaceEnrichmentResult | undefined {
try {
const raw = sessionStorage.getItem(key)
return raw ? (JSON.parse(raw) as MapsPlaceEnrichmentResult) : undefined
} catch {
return undefined
}
}
function writeSession(key: string, value: MapsPlaceEnrichmentResult): void {
try {
sessionStorage.setItem(key, JSON.stringify(value))
} catch {
/* private mode / quota — the in-memory cache still helps for this session */
}
}
/**
* Bumped with the server's CACHE_VERSION. sessionStorage outlives a deploy, so
* without it the tab that was open while the fix shipped keeps replaying the
* answer the fix was about — and reports it as still broken.
*/
const ENRICH_CACHE_V = 4
function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string {
const id = selection.placeId || `coords:${selection.lat}:${selection.lng}`
return `enrich_v${ENRICH_CACHE_V}_${id}_${language}`
}
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
/**
@@ -119,7 +161,6 @@ export default function PlaceDetailsColumn({
locale = 'en-US',
fluid = false,
variant = 'sheet',
image,
t,
}: PlaceDetailsColumnProps): React.ReactElement {
const dialog = variant === 'dialog'
@@ -142,8 +183,9 @@ export default function PlaceDetailsColumn({
return
}
const cached = readCachedEnrichment(selection, language)
const cached = enrichmentCache.get(selectionKey) ?? readSession(selectionKey)
if (cached) {
enrichmentCache.set(selectionKey, cached)
setData(cached)
setState('ready')
return
@@ -175,7 +217,8 @@ export default function PlaceDetailsColumn({
)
.then((result) => {
if (controller.signal.aborted) return
storeEnrichment(selection, language, result)
enrichmentCache.set(selectionKey, result)
writeSession(selectionKey, result)
setData(result)
setState('ready')
})
@@ -236,16 +279,6 @@ export default function PlaceDetailsColumn({
)}
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}>
{/* The place's picture first: its own ones are there whatever the sources answer. */}
<PictureBlock
photos={state === 'ready' && !data?.disabled ? (data?.photos ?? []) : []}
chosen={image?.chosen ?? chosenImage(selectedImageUrl, null, null)}
onPickImage={onPickImage}
own={image?.own}
dialog={dialog}
t={t}
/>
{/* Nothing picked yet, which is a state rather than a fault: the mascot
waits with an idle look instead of a sad one. The dialog says the
same in a quiet card, and the column keeps its width either way. */}
@@ -298,6 +331,7 @@ export default function PlaceDetailsColumn({
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
<>
<PhotoStrip photos={data?.photos ?? []} selectedImageUrl={selectedImageUrl} onPickImage={onPickImage} dialog={dialog} t={t} />
<RatingRow rating={data?.rating ?? null} locale={locale} dialog={dialog} />
<OpeningHoursBlock
hours={data?.hours ?? null}
@@ -355,31 +389,119 @@ export default function PlaceDetailsColumn({
)
}
/** The picker under its label; nothing while there is no picture to offer. */
function PictureBlock({
/** The name to put under a picture. Google gives no author, so it gets its own name. */
function creditOf(photo: PlacePhotoCandidate): string {
return photo.attribution || sourceLabelFor(photo.source)
}
/**
* The picture grid.
*
* A grid rather than one large image: this sits beside a form in a dialog, and
* a picture that takes half the column pushes the facts and the description out
* of sight. Three columns at 320px gives ~96px tiles, which is enough to tell
* the pictures apart while leaving the rest of the column visible.
*
* Only the picture in play is credited in full — crediting all of them at once
* cost two lines each and drowned the column, and the licence obligation
* attaches to the one that gets used. Every tile still carries the full credit
* as its tooltip and links to its source page.
*/
function PhotoStrip({
photos,
chosen,
selectedImageUrl,
onPickImage,
own,
dialog,
dialog = false,
t,
}: {
photos: PlacePhotoCandidate[]
chosen: ChosenImage
selectedImageUrl?: string
onPickImage: (url: string | null) => void
own?: PlaceImageOwn
dialog: boolean
dialog?: boolean
t: TranslationFn
}): React.ReactElement | null {
if (!offersPictures(photos, own)) return null
const picker = <PlaceImagePicker photos={photos} chosen={chosen} onPickUrl={onPickImage} own={own} dialog={dialog} t={t} />
const [hovered, setHovered] = useState<string | null>(null)
if (photos.length === 0) return null
const shown = photos.find((p) => p.url === (hovered ?? selectedImageUrl)) ?? photos[0]
const body = (
<>
<div className="grid grid-cols-3 gap-1.5">
{photos.map((photo) => (
<PhotoTile
key={photo.key}
photo={photo}
selected={selectedImageUrl === photo.url}
onPick={onPickImage}
onHover={setHovered}
dialog={dialog}
t={t}
/>
))}
</div>
<p className={dialog ? 'mt-2 flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint' : 'text-caption leading-tight text-content-faint truncate'}>
<PhotoCredit photo={shown} dialog={dialog} />
</p>
</>
)
return (
<Block label={t('places.details.pickImage')} dialog={dialog}>
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{picker}</div> : picker}
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{body}</div> : body}
</Block>
)
}
function PhotoTile({
photo,
selected,
onPick,
onHover,
dialog = false,
t,
}: {
photo: PlacePhotoCandidate
selected: boolean
onPick: (url: string | null) => void
onHover: (url: string | null) => void
/** On a white card, and without the native tooltip: hovering a tile already moves the credit line under the grid to it. */
dialog?: boolean
t: TranslationFn
}): React.ReactElement {
const credit = creditOf(photo)
return (
<button
type="button"
onClick={() => onPick(selected ? null : photo.url)}
onMouseEnter={() => onHover(photo.url)}
onMouseLeave={() => onHover(null)}
onFocus={() => onHover(photo.url)}
onBlur={() => onHover(null)}
aria-pressed={selected}
aria-label={`${t('places.details.pickImage')} — ${credit}`}
title={dialog ? undefined : `${credit}${photo.license ? ` · ${photo.license}` : ''}`}
className={`group relative block w-full aspect-square overflow-hidden rounded-lg transition-shadow ${
selected
? `ring-2 ring-accent ring-offset-2 ${dialog ? 'ring-offset-surface-card' : 'ring-offset-surface-secondary'}`
: 'ring-1 ring-edge hover:ring-content-muted'
}`}
>
<img
src={photo.url}
alt=""
loading="lazy"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
/>
{selected && (
<span className="absolute top-1 end-1 rounded-full bg-accent p-0.5 shadow-card">
<Check className="w-2.5 h-2.5 text-accent-on" />
</span>
)}
</button>
)
}
/**
* Who the description came from, as the reader would name them.
*
@@ -411,6 +533,75 @@ function descriptionSourceLabel(
}
}
function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
if (source === 'google') return 'Google'
if (source === 'wikipedia') return 'Wikipedia'
return 'Wikimedia Commons'
}
/**
* Author and licence for the picture currently in play.
*
* Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one
* without naming its author does not satisfy those terms. When a source hands
* us no author (Google), we say where it came from rather than inventing one.
* Rendered inline — the container supplies the colour and the truncation.
*/
function PhotoCredit({ photo, dialog = false }: { photo: PlacePhotoCandidate; dialog?: boolean }): React.ReactElement {
const credit = creditOf(photo)
if (dialog) {
// Two pieces side by side with room between them, rather than joined by a dot.
return (
<>
<span className="min-w-0 truncate">
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
</span>
{photo.license && (
<span className="flex-none">
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</span>
)}
</>
)
}
return (
<>
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
{photo.license && (
<>
{' · '}
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</>
)}
</>
)
}
/**
* Shown when the free sources found nothing and no Google key is configured.
@@ -1,65 +0,0 @@
// FE-PLANNER-PLFORMMEDIA-001 to FE-PLANNER-PLFORMMEDIA-003: the files waiting for a new
// place, and the place's picture in the form where no details column stands beside it.
import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { PendingFiles, PlaceImageSection } from './PlaceFormMedia';
import type { PlaceImageOwn } from './PlaceImagePicker';
vi.mock('../../api/authUrl', () => ({ fetchImageAsBlob: vi.fn(async () => 'blob:x') }));
const t = ((key: string) => key) as never;
describe('PendingFiles', () => {
it('FE-PLANNER-PLFORMMEDIA-001: the attach button opens the input, which adds the files', () => {
const fileRef = createRef<HTMLInputElement>();
const onAdd = vi.fn();
render(<PendingFiles files={[]} fileRef={fileRef} onAdd={onAdd} onRemove={vi.fn()} t={t} />);
expect(screen.queryByTestId('pending-files')).not.toBeInTheDocument();
const openPicker = vi.spyOn(fileRef.current as HTMLInputElement, 'click');
fireEvent.click(screen.getByRole('button', { name: 'files.attach' }));
expect(openPicker).toHaveBeenCalledTimes(1);
fireEvent.change(fileRef.current as HTMLInputElement, { target: { files: [new File(['x'], 'a.pdf')] } });
expect(onAdd).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLFORMMEDIA-002: each waiting file is listed by name and removed by its index', () => {
const onRemove = vi.fn();
render(
<PendingFiles
files={[new File(['x'], 'ticket.pdf'), new File(['y'], 'map.png')]}
fileRef={createRef<HTMLInputElement>()}
onAdd={vi.fn()}
onRemove={onRemove}
t={t}
/>
);
expect(screen.getByTestId('pending-files')).toHaveTextContent('ticket.pdf');
expect(screen.getByText('map.png')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[1]);
expect(onRemove).toHaveBeenCalledWith(1);
});
});
describe('PlaceImageSection', () => {
it("FE-PLANNER-PLFORMMEDIA-003: the picker sits under the field's label with the place's own pictures", () => {
const own: PlaceImageOwn = {
currentUrl: '/uploads/places/c.jpg',
attached: [],
pendingPreview: null,
canUpload: true,
onUpload: vi.fn(),
onPickAttached: vi.fn(),
onRemove: vi.fn(),
};
const onPickUrl = vi.fn();
render(<PlaceImageSection image={{ own, chosen: { kind: 'none' } }} onPickUrl={onPickUrl} t={t} />);
expect(screen.getByText('places.details.pickImage')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'places.uploadImage' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'places.image.current' }));
expect(onPickUrl).toHaveBeenCalledWith('/uploads/places/c.jpg');
});
});
@@ -1,84 +0,0 @@
import { Paperclip, X } from 'lucide-react';
import React from 'react';
import type { TranslationFn } from '../../types';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { LABEL } from '../shared/dialogParts';
import PlaceImagePicker, { type PlaceImageOwn } from './PlaceImagePicker';
import { COLUMN_CARD, WHITE_BUTTON } from './placeDialogParts';
import { offersPictures, type ChosenImage } from './placeImageChoice';
/** Files chosen before the place exists: they are uploaded once the save has given it an id. */
export function PendingFiles({
files,
fileRef,
onAdd,
onRemove,
t,
}: {
files: File[];
fileRef: React.RefObject<HTMLInputElement | null>;
onAdd: (e: React.ChangeEvent<HTMLInputElement>) => void;
onRemove: (index: number) => void;
t: TranslationFn;
}) {
return (
<div className="min-w-0">
<div className={LABEL}>{t('files.title')}</div>
<input ref={fileRef} type="file" multiple className="hidden" onChange={onAdd} />
<button type="button" onClick={() => fileRef.current?.click()} className={WHITE_BUTTON} style={fs(13, 'body')}>
<Paperclip size={14} aria-hidden="true" />
{t('files.attach')}
</button>
{files.length > 0 && (
<div className="mt-1.5 flex flex-col gap-1" data-testid="pending-files">
{files.map((file, idx) => (
<div
key={idx}
className="flex items-center gap-2 rounded-[8px] border border-edge-faint bg-surface-card px-2.5 py-1.5"
style={fs(12, 'body')}
>
<Paperclip size={11} className="flex-none text-content-faint" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-content-secondary">{file.name}</span>
<Tooltip label={t('common.delete')}>
<button
type="button"
onClick={() => onRemove(idx)}
aria-label={t('common.delete')}
className="flex flex-none text-content-faint transition-colors hover:text-danger"
>
<X size={12} />
</button>
</Tooltip>
</div>
))}
</div>
)}
</div>
);
}
/**
* The place's picture in the form itself, where no details column stands
* beside it (place details switched off): the same picker, with the place's
* own pictures and an upload, under the field's label.
*/
export function PlaceImageSection({
image,
onPickUrl,
t,
}: {
image: { own: PlaceImageOwn; chosen: ChosenImage };
onPickUrl: (url: string | null) => void;
t: TranslationFn;
}) {
if (!offersPictures([], image.own)) return null;
return (
<div className="min-w-0">
<div className={LABEL}>{t('places.details.pickImage')}</div>
<div className={`${COLUMN_CARD} p-2`}>
<PlaceImagePicker photos={[]} chosen={image.chosen} onPickUrl={onPickUrl} own={image.own} dialog t={t} />
</div>
</div>
);
}
@@ -1,8 +1,8 @@
// FE-PLANNER-PLFORMH-001 to FE-PLANNER-PLFORMH-011: the place form's openings and save
// FE-PLANNER-PLFORMH-001 to FE-PLANNER-PLFORMH-010: the place form's openings and save
// payload, as the desktop dialog and the phone sheet both build them.
import { describe, expect, it } from 'vitest';
import type { Assignment, Place, TripFile } from '../../types';
import type { Assignment, Place } from '../../types';
import {
DEFAULT_FORM,
duplicateName,
@@ -155,18 +155,4 @@ describe('placeFormPayload', () => {
const payload = placeFormPayload(form, [], null, (lat, lng) => ({ at: [lat, lng] }));
expect(payload.at).toEqual([48.86, null]);
});
it('FE-PLANNER-PLFORMH-011: a waiting picture travels as the file, or as the attached file id, or not at all', () => {
expect(placeFormPayload(form, [], null)._pendingImage).toBeUndefined();
const picture = new File(['x'], 'front.png');
expect(
placeFormPayload(form, [], null, undefined, { kind: 'upload', file: picture, preview: 'blob:x' })._pendingImage
).toBe(picture);
const attached = { id: 12, mime_type: 'image/png' } as TripFile;
expect(placeFormPayload(form, [], null, undefined, { kind: 'attached', file: attached })._pendingImage).toEqual({
fileId: 12,
});
// A dropped picture is the field's null, and it is saved as such.
expect(placeFormPayload({ ...form, image_url: null }, [], null).image_url).toBeNull();
});
});
@@ -1,5 +1,4 @@
import type { Assignment, Place, RoadtripStopType } from '@trek/shared'
import { pendingImagePayload, type PendingImagePayload, type PendingPlaceImage } from './placeImageChoice'
import type { PlaceDetailsSelection } from './PlaceDetailsColumn'
export interface PlaceFormData {
@@ -28,8 +27,7 @@ export interface PlaceFormData {
// Hero image picked from the detail column. Optional and absent from
// DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets
// it, and places.service already writes image_url through on create/update.
// Null drops the place's picture on save; undefined keeps it.
image_url?: string | null
image_url?: string
// Day-specific note on the in-context assignment (#2163). Only hydrated when
// the form opened with an assignment in context; both forms drop it from the
// submit payload when unchanged, and useTripPlanner strips it off the place
@@ -130,7 +128,7 @@ export function placeEditForm(
}
/** A position the form opens on: a POI tapped on the map, or a right-click on it. */
export type PlacePrefill = {
export interface PlacePrefill {
lat: number
lng: number
name?: string
@@ -138,8 +136,6 @@ export type PlacePrefill = {
website?: string
phone?: string
osm_id?: string
/** A place Google knows, such as one the assistant found: the details column reads it by this id. */
google_place_id?: string
}
/** A blank form at a prefilled position, with what the POI already knew. */
@@ -153,7 +149,6 @@ export function prefillForm(prefill: PlacePrefill, extra: Partial<PlaceFormData>
website: prefill.website || '',
phone: prefill.phone || '',
osm_id: prefill.osm_id,
google_place_id: prefill.google_place_id,
...extra,
}
}
@@ -175,7 +170,7 @@ export function openingDetailsSelection(
}
}
if (prefill) {
return { placeId: prefill.google_place_id || prefill.osm_id || undefined, lat: prefill.lat, lng: prefill.lng, name: prefill.name || '' }
return { placeId: prefill.osm_id || undefined, lat: prefill.lat, lng: prefill.lng, name: prefill.name || '' }
}
return null
}
@@ -310,8 +305,6 @@ export interface PlaceFormPayload extends Omit<PlaceFormData, 'lat' | 'lng' | 'c
category_id: string | null
/** Files chosen before the place existed: uploaded once the save has given it an id. */
_pendingFiles?: File[]
/** A picture waiting to become the place's own: a file from the device, or an attached file's id. */
_pendingImage?: PendingImagePayload
}
/**
@@ -326,7 +319,6 @@ export function placeFormPayload<E extends object = object>(
pendingFiles: File[],
assignment: Pick<Assignment, 'notes'> | null | undefined,
extra?: (lat: number | null, lng: number | null) => E,
pendingImage: PendingPlaceImage | null = null,
): PlaceFormPayload & E {
const lat = form.lat ? Number.parseFloat(form.lat) : null
const lng = form.lng ? Number.parseFloat(form.lng) : null
@@ -337,7 +329,6 @@ export function placeFormPayload<E extends object = object>(
category_id: form.category_id || null,
...(extra ? extra(lat, lng) : {}),
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
_pendingImage: pendingImagePayload(pendingImage),
} as PlaceFormPayload & E
if (!assignment || (form.assignment_notes ?? '') === (assignment.notes ?? '')) {
delete payload.assignment_notes
@@ -1,89 +0,0 @@
// FE-PLANNER-PLACEFORM-125 to FE-PLANNER-PLACEFORM-127: the place's picture in the desktop
// dialog. Nothing is sent before the save; the waiting picture travels with it.
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace, buildTripFile } from '../../../tests/helpers/factories';
import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeFormSetup';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import PlaceFormModal from './PlaceFormModal';
vi.mock('../shared/CustomTimePicker', async () => ({
default: (await import('../../../tests/helpers/placeForm')).TimePickerStub,
}));
vi.mock('../../api/authUrl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/authUrl')>()),
fetchImageAsBlob: vi.fn(async () => 'blob:attached'),
}));
const imageInput = () => document.querySelector('input[type="file"][accept^="image"]') as HTMLInputElement;
beforeEach(() => {
resetAllStores();
seedPlaceFormStores();
// Without the details column the picker sits in the form itself.
seedStore(useAuthStore, { placesEnrichEnabled: false });
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:form-preview');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('PlaceFormModal, the picture', () => {
it('FE-PLANNER-PLACEFORM-125: a new place gets a picture from the device, uploaded once the save made it', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
render(<PlaceFormModal {...placeFormProps} onSave={onSave} />);
expect(screen.getByText('Pick a picture')).toBeInTheDocument();
const picture = new File(['x'], 'front.png', { type: 'image/png' });
fireEvent.change(imageInput(), { target: { files: [picture] } });
const tile = await screen.findByRole('button', { name: 'Upload image', pressed: true });
expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:form-preview');
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Louvre', _pendingImage: picture }));
});
it('FE-PLANNER-PLACEFORM-126: an edited place keeps its picture as chosen, or takes an attached image by its id', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const place = buildPlace({ id: 61, name: 'Gallery', image_url: '/uploads/places/61.jpg' });
useTripStore.setState({
files: [buildTripFile({ id: 88, place_id: 61, mime_type: 'image/jpeg', original_name: 'hall.jpg' })],
} as never);
render(<PlaceFormModal {...placeFormProps} place={place} onSave={onSave} />);
expect(screen.getByRole('button', { name: 'Current picture' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: 'hall.jpg' }));
expect(screen.getByRole('button', { name: 'hall.jpg' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Current picture' })).toHaveAttribute('aria-pressed', 'false');
await user.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ _pendingImage: { fileId: 88 } }));
});
it('FE-PLANNER-PLACEFORM-127: removing the picture saves the place without one, and nothing waits', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const place = buildPlace({ id: 62, name: 'Bridge', image_url: '/uploads/places/62.jpg' });
render(<PlaceFormModal {...placeFormProps} place={place} onSave={onSave} />);
await user.click(screen.getByRole('button', { name: 'Remove image' }));
expect(screen.getByRole('button', { name: 'Current picture' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByText('No picture')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
const payload = onSave.mock.calls[0][0];
expect(payload.image_url).toBeNull();
expect(payload._pendingImage).toBeUndefined();
});
});
@@ -913,7 +913,7 @@ describe('PlaceFormModal', () => {
it('FE-PLANNER-PLACEFORM-031: pending files list shows file names after adding', async () => {
render(<PlaceFormModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput).toBeTruthy();
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
@@ -925,7 +925,7 @@ describe('PlaceFormModal', () => {
it('FE-PLANNER-PLACEFORM-032: removing a pending file removes it from the list', async () => {
const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['x'], 'remove-me.jpg', { type: 'image/jpeg' });
fireEvent.change(fileInput, { target: { files: [file] } });
@@ -1470,7 +1470,7 @@ describe('PlaceFormModal', () => {
it('FE-PLANNER-PLACEFORM-057: the Attach button opens the hidden file input', async () => {
const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
await user.click(screen.getByRole('button', { name: /Attach/i }));
expect(clickSpy).toHaveBeenCalled();
@@ -2422,7 +2422,7 @@ describe('PlaceFormModal on the dialog frame', () => {
it('FE-PLANNER-PLACEFORM-103: a pending file is removed by its named button', async () => {
const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />);
fireEvent.change(document.querySelector('input[type="file"][multiple]') as HTMLInputElement, {
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
target: { files: [new File(['x'], 'ticket.pdf', { type: 'application/pdf' })] },
});
await user.click(within(screen.getByTestId('pending-files')).getByRole('button', { name: 'Delete' }));
@@ -8,18 +8,16 @@ import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn from './PlaceDetailsColumn'
import { PendingFiles, PlaceImageSection } from './PlaceFormMedia'
import { usePlaceImageField } from './usePlaceImageField'
import { useToast } from '../shared/Toast'
import { Tooltip } from '../shared/Tooltip'
import { Search, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react'
import { Search, Paperclip, X, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { PlaceContactFields } from './PlaceContactFields'
import { weekFromPeriods } from './placeHours'
import type { PlaceOpeningHours } from '@trek/shared'
import {
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
prefillForm, type PlaceFormData, type PlacePrefill,
prefillForm, type PlaceFormData,
} from './PlaceFormModal.helpers'
import { useNewCategory, usePlaceForm } from './usePlaceForm'
import { usePlaceSearch } from './usePlaceSearch'
@@ -43,6 +41,7 @@ import {
} from '../shared/DialogShell'
import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts'
import { SoftPill, tintOf } from './planParts'
import { WHITE_BUTTON } from './placeDialogParts'
import { TimeSection } from './PlaceFormTimeSection'
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
@@ -65,7 +64,7 @@ interface PlaceFormModalProps {
onClose: () => void
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
place: Place | null
prefillCoords?: (PlacePrefill & { stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string }) | null
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null
tripId: number
categories: Category[]
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
@@ -122,11 +121,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const {
form, setForm, autoFilledRef, pendingFiles, addFilesFromInput, removeFile, handlePaste, isSaving, setIsSaving,
duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange: changeField, pickImage,
adoptDescription, warnIfDuplicate, pendingImage, stageUpload, stageAttached, removeImage,
adoptDescription, warnIfDuplicate,
} = usePlaceForm({ t, toast, canUploadFiles })
const image = usePlaceImageField({
place, imageUrl: form.image_url, pendingImage, canUploadFiles, stageUpload, stageAttached, removeImage,
})
// The index the picked place came from, as its row named it, shown as a pill
// in the head band. Null for a place typed by hand or taken from the saved list.
const [pickedSource, setPickedSource] = useState<string | null>(null)
@@ -362,7 +358,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
...(serviceStop
? { _serviceStop: serviceStopChoice(serviceStop, lat, lng, serviceStopLeg).placement }
: {}),
}), pendingImage)
}))
const saved = await onSave(payload)
// Open the Costs editor for the saved place when the user asked to
// create/edit its linked expense — gated on an id, so a create that the
@@ -446,7 +442,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
handleRemoveFile: removeFile,
handlePaste,
pickImage,
image,
adoptDescription,
hasTimeError,
handleSubmit,
@@ -611,7 +606,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
selection={detailsSelection}
selectedImageUrl={form.image_url}
onPickImage={S.pickImage}
image={S.image}
onAdoptDescription={S.adoptDescription}
onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))}
hasDescription={!!form.description.trim()}
@@ -885,7 +879,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
maxLength={2000}
/>
{!showDetails && <PlaceImageSection image={S.image} onPickUrl={S.pickImage} t={t} />}
{/* The link and the files side by side, as on a booking. */}
<div className={canUploadFiles ? GRID_2 : ''}>
<EditorField label={t('places.formWebsite')} htmlFor={`${fieldId}-website`}>
@@ -1124,3 +1117,44 @@ function MarkdownField({ id, label, value, onChange, placeholder, maxLength }: {
</DialogSection>
)
}
/** Files chosen before the place exists: they are uploaded once the save has given it an id. */
function PendingFiles({ files, fileRef, onAdd, onRemove, t }: {
files: File[]
fileRef: React.RefObject<HTMLInputElement | null>
onAdd: (e: React.ChangeEvent<HTMLInputElement>) => void
onRemove: (index: number) => void
t: Translate
}) {
return (
<div className="min-w-0">
<div className={LABEL}>{t('files.title')}</div>
<input ref={fileRef} type="file" multiple className="hidden" onChange={onAdd} />
<button
type="button"
onClick={() => fileRef.current?.click()}
className={WHITE_BUTTON}
style={fs(13, 'body')}
>
<Paperclip size={14} aria-hidden="true" />
{t('files.attach')}
</button>
{files.length > 0 && (
<div className="mt-1.5 flex flex-col gap-1" data-testid="pending-files">
{files.map((file, idx) => (
<div key={idx} className="flex items-center gap-2 rounded-[8px] border border-edge-faint bg-surface-card px-2.5 py-1.5" style={fs(12, 'body')}>
<Paperclip size={11} className="flex-none text-content-faint" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-content-secondary">{file.name}</span>
<Tooltip label={t('common.delete')}>
<button type="button" onClick={() => onRemove(idx)} aria-label={t('common.delete')}
className="flex flex-none text-content-faint transition-colors hover:text-danger">
<X size={12} />
</button>
</Tooltip>
</div>
))}
</div>
)}
</div>
)
}
@@ -1,258 +0,0 @@
// FE-PLANNER-PLIMGPICK-001 to FE-PLANNER-PLIMGPICK-016: the editor's picture picker, the one
// place a place's picture is chosen: the upload tile, the current picture, the images
// attached to the place, the sources' photos, the credit line and "Remove image".
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { PlacePhotoCandidate } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import { placePictureRepo } from '../../repo/placePictureRepo';
import { normalizeImageFile } from '../../utils/convertHeic';
import PlaceImagePicker, { PhotoCredit, type PlaceImageOwn } from './PlaceImagePicker';
import type { ChosenImage } from './placeImageChoice';
vi.mock('../../repo/placePictureRepo', () => ({ placePictureRepo: { attachedImage: vi.fn() } }));
vi.mock('../../utils/convertHeic', () => ({
normalizeImageFile: vi.fn(async (file: File) => new File([file], `normalized-${file.name}`, { type: 'image/jpeg' })),
}));
/** Echo the key back, which keeps the assertions readable. */
const t = ((key: string) => key) as never;
const CURRENT = '/uploads/places/current.jpg';
const COMMONS: PlacePhotoCandidate = {
key: 'w~p0',
url: '/api/maps/place-photo/w~p0/bytes',
attribution: 'Alice',
license: 'CC BY-SA 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
sourceUrl: 'https://commons.wikimedia.org/wiki/File:A.jpg',
source: 'wikimedia',
} as PlacePhotoCandidate;
const GOOGLE: PlacePhotoCandidate = {
key: 'w~p1',
url: '/api/maps/place-photo/w~p1/bytes',
attribution: null,
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'google',
} as PlacePhotoCandidate;
const ATTACHED = buildTripFile({ id: 41, original_name: 'facade.png', mime_type: 'image/png', url: '/api/files/41' });
function own(over: Partial<PlaceImageOwn> = {}): PlaceImageOwn {
return {
currentUrl: null,
attached: [],
pendingPreview: null,
canUpload: true,
onUpload: vi.fn(),
onPickAttached: vi.fn(),
onRemove: vi.fn(),
...over,
};
}
function renderPicker(
props: Partial<{
photos: PlacePhotoCandidate[];
chosen: ChosenImage;
own: PlaceImageOwn;
dialog: boolean;
}> = {}
) {
const onPickUrl = vi.fn();
const utils = render(
<PlaceImagePicker
photos={props.photos ?? []}
chosen={props.chosen ?? { kind: 'none' }}
onPickUrl={onPickUrl}
own={props.own}
dialog={props.dialog}
t={t}
/>
);
return { ...utils, onPickUrl };
}
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
beforeEach(() => {
vi.mocked(placePictureRepo.attachedImage).mockReset();
vi.mocked(placePictureRepo.attachedImage).mockResolvedValue('blob:attached');
vi.mocked(normalizeImageFile).mockClear();
});
describe('PlaceImagePicker', () => {
it('FE-PLANNER-PLIMGPICK-001: renders nothing while there is no picture to offer and no upload', () => {
const bare = renderPicker();
expect(bare.container).toBeEmptyDOMElement();
bare.unmount();
const readOnly = renderPicker({ own: own({ canUpload: false }) });
expect(readOnly.container).toBeEmptyDOMElement();
});
it('FE-PLANNER-PLIMGPICK-002: the upload tile opens the file picker and hands the converted file over', async () => {
const mine = own();
renderPicker({ own: mine });
const tile = screen.getByRole('button', { name: 'places.uploadImage' });
expect(tile).toHaveAttribute('aria-pressed', 'false');
const input = fileInput();
expect(input).toHaveAttribute('accept', 'image/*,.heic,.heif');
const openPicker = vi.spyOn(input, 'click');
fireEvent.click(tile);
expect(openPicker).toHaveBeenCalledTimes(1);
const picture = new File(['x'], 'photo.heic', { type: 'image/heic' });
fireEvent.change(input, { target: { files: [picture] } });
await waitFor(() => expect(mine.onUpload).toHaveBeenCalledTimes(1));
expect(normalizeImageFile).toHaveBeenCalledWith(picture);
expect(vi.mocked(mine.onUpload).mock.calls[0][0].name).toBe('normalized-photo.heic');
await waitFor(() => expect(tile).not.toBeDisabled());
});
it('FE-PLANNER-PLIMGPICK-003: an emptied file choice hands nothing over', () => {
const mine = own();
renderPicker({ own: mine });
fireEvent.change(fileInput(), { target: { files: [] } });
expect(normalizeImageFile).not.toHaveBeenCalled();
expect(mine.onUpload).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLIMGPICK-004: a picture waiting for the save shows as its preview, chosen and credited as yours', () => {
renderPicker({ own: own({ pendingPreview: 'blob:preview' }), chosen: { kind: 'upload' } });
const tile = screen.getByRole('button', { name: 'places.uploadImage' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:preview');
expect(screen.getByText('places.image.own')).toBeInTheDocument();
// A waiting upload can be dropped again.
expect(screen.getByRole('button', { name: 'places.removeImage' })).toBeInTheDocument();
});
it('FE-PLANNER-PLIMGPICK-005: the current picture is chosen by default, and a second tap removes it', () => {
const mine = own({ currentUrl: CURRENT });
renderPicker({ own: mine, chosen: { kind: 'current' } });
const tile = screen.getByRole('button', { name: 'places.image.current' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
expect(tile.querySelector('img')).toHaveAttribute('src', CURRENT);
expect(screen.getByText('places.image.current')).toBeInTheDocument();
fireEvent.click(tile);
expect(mine.onRemove).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLIMGPICK-006: once dropped, the current picture is taken back with a tap', () => {
const mine = own({ currentUrl: CURRENT });
const { onPickUrl } = renderPicker({ own: mine, chosen: { kind: 'none' } });
// The line under the grid says what the save will do.
expect(screen.getByText('places.image.none')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'places.removeImage' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'places.image.current' }));
expect(onPickUrl).toHaveBeenCalledWith(CURRENT);
expect(mine.onRemove).not.toHaveBeenCalled();
});
it("FE-PLANNER-PLIMGPICK-007: a current picture that is one of the sources' photos is not shown twice", () => {
renderPicker({ photos: [COMMONS], own: own({ currentUrl: COMMONS.url }), chosen: { kind: 'current' } });
expect(screen.queryByRole('button', { name: 'places.image.current' })).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /places.details.pickImage/ })).toHaveLength(1);
});
it('FE-PLANNER-PLIMGPICK-008: an attached image loads through the file route and is picked with a tap', async () => {
const mine = own({ attached: [ATTACHED] });
renderPicker({ own: mine });
const tile = screen.getByRole('button', { name: 'facade.png' });
await waitFor(() => expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:attached'));
expect(placePictureRepo.attachedImage).toHaveBeenCalledWith('/api/files/41');
fireEvent.click(tile);
expect(mine.onPickAttached).toHaveBeenCalledWith(ATTACHED);
});
it('FE-PLANNER-PLIMGPICK-009: a chosen attached image is ticked and named under the grid; one that fails to load stays blank', async () => {
vi.mocked(placePictureRepo.attachedImage).mockRejectedValue(new Error('gone'));
renderPicker({ own: own({ attached: [ATTACHED] }), chosen: { kind: 'attached', fileId: 41 } });
const tile = screen.getByRole('button', { name: 'facade.png' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('facade.png')).toBeInTheDocument();
await waitFor(() => expect(placePictureRepo.attachedImage).toHaveBeenCalled());
expect(tile.querySelector('img')).toBeNull();
// Focusing it names it as well, and the name stays once it is left.
fireEvent.focus(tile);
expect(screen.getByText('facade.png')).toBeInTheDocument();
fireEvent.blur(tile);
expect(screen.getByText('facade.png')).toBeInTheDocument();
});
it("FE-PLANNER-PLIMGPICK-010: a source's photo is picked with a tap and let go with a second", () => {
const first = renderPicker({ photos: [COMMONS, GOOGLE] });
fireEvent.click(screen.getByRole('button', { name: 'places.details.pickImage: Alice' }));
expect(first.onPickUrl).toHaveBeenCalledWith(COMMONS.url);
first.unmount();
const chosen = renderPicker({ photos: [COMMONS, GOOGLE], chosen: { kind: 'url', url: GOOGLE.url } });
const tile = screen.getByRole('button', { name: 'places.details.pickImage: Google' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(tile);
expect(chosen.onPickUrl).toHaveBeenCalledWith(null);
});
it('FE-PLANNER-PLIMGPICK-011: the chosen photo is credited with its author and licence, each linked', () => {
renderPicker({ photos: [COMMONS, GOOGLE], chosen: { kind: 'url', url: COMMONS.url } });
expect(screen.getByText('Alice').closest('a')).toHaveAttribute('href', COMMONS.sourceUrl);
expect(screen.getByText('CC BY-SA 4.0').closest('a')).toHaveAttribute('href', COMMONS.licenseUrl);
// Hovering another tile moves the credit line to it, leaving moves it back.
const google = screen.getByRole('button', { name: 'places.details.pickImage: Google' });
fireEvent.mouseEnter(google);
expect(screen.getByText('Google')).toBeInTheDocument();
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
fireEvent.mouseLeave(google);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('FE-PLANNER-PLIMGPICK-012: without the own pictures the first photo is credited and nothing can be removed', () => {
renderPicker({ photos: [COMMONS], chosen: { kind: 'url', url: '/not/among/them.jpg' } });
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'places.removeImage' })).not.toBeInTheDocument();
});
it('FE-PLANNER-PLIMGPICK-013: "Remove image" leaves the place without a picture', () => {
const mine = own({ currentUrl: CURRENT });
renderPicker({ own: mine, photos: [COMMONS], chosen: { kind: 'url', url: COMMONS.url } });
fireEvent.click(screen.getByRole('button', { name: 'places.removeImage' }));
expect(mine.onRemove).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLIMGPICK-014: the sheet gives each photo a native tooltip, the dialog does not', () => {
const sheet = renderPicker({ photos: [COMMONS, GOOGLE] });
expect(screen.getByRole('button', { name: 'places.details.pickImage: Alice' })).toHaveAttribute(
'title',
'Alice, CC BY-SA 4.0'
);
expect(screen.getByRole('button', { name: 'places.details.pickImage: Google' })).toHaveAttribute('title', 'Google');
sheet.unmount();
renderPicker({ photos: [COMMONS], dialog: true });
expect(screen.getByRole('button', { name: 'places.details.pickImage: Alice' })).not.toHaveAttribute('title');
});
});
describe('PhotoCredit', () => {
it('FE-PLANNER-PLIMGPICK-015: a photo without links is still credited in plain text', () => {
render(<PhotoCredit photo={{ ...COMMONS, sourceUrl: null, licenseUrl: null }} />);
expect(screen.getByText('Alice').closest('a')).toBeNull();
expect(screen.getByText('CC BY-SA 4.0').closest('a')).toBeNull();
});
it('FE-PLANNER-PLIMGPICK-016: a photo without a licence shows only its credit', () => {
const { container } = render(<PhotoCredit photo={GOOGLE} />);
expect(container).toHaveTextContent(/^Google$/);
});
});
@@ -1,374 +0,0 @@
import type { PlacePhotoCandidate } from '@trek/shared';
import { Check, ImageOff, ImagePlus, Loader2 } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { placePictureRepo } from '../../repo/placePictureRepo';
import type { TranslationFn, TripFile } from '../../types';
import { normalizeImageFile } from '../../utils/convertHeic';
import { Tooltip } from '../shared/Tooltip';
import { offersPictures, type ChosenImage } from './placeImageChoice';
import { creditOf } from './placePhotoCredit';
/** The place's own pictures beside the sources' ones: what it has, what it can get, what waits for the save. */
export interface PlaceImageOwn {
/** The picture the place has now; null on a new place or one without. */
currentUrl: string | null;
/** Images attached to the place that can stand as its picture. */
attached: TripFile[];
/** The picture from the device that waits for the save, as a preview. */
pendingPreview: string | null;
/** False where the user may not upload files; the upload tile is then left out. */
canUpload: boolean;
onUpload: (file: File) => void;
onPickAttached: (file: TripFile) => void;
onRemove: () => void;
}
interface PlaceImagePickerProps {
/** What the sources know, as found for the place; empty while they load or when there are none. */
photos: PlacePhotoCandidate[];
chosen: ChosenImage;
onPickUrl: (url: string | null) => void;
/** Left out where only the sources' pictures are offered. */
own?: PlaceImageOwn;
/** On a white card, without native tooltips: hovering moves the credit line instead. */
dialog?: boolean;
t: TranslationFn;
}
const TILE = 'group relative block aspect-square w-full overflow-hidden rounded-lg transition-shadow';
function ring(selected: boolean, dialog: boolean): string {
if (selected)
return `ring-2 ring-accent ring-offset-2 ${dialog ? 'ring-offset-surface-card' : 'ring-offset-surface-secondary'}`;
return 'ring-1 ring-edge hover:ring-content-muted';
}
function Picked(): React.ReactElement {
return (
<span className="absolute end-1 top-1 rounded-full bg-accent p-0.5 shadow-card">
<Check className="h-2.5 w-2.5 text-accent-on" />
</span>
);
}
/** What the credit line under the grid says: a source's photo with its author and licence, or the place's own. */
type Credit = { kind: 'photo'; photo: PlacePhotoCandidate } | { kind: 'own'; label: string } | null;
/**
* The one place a place's picture is chosen, in the editor: a tile to upload
* one from the device, the picture it has now, the images attached to it and
* the pictures its sources know. The chosen tile is ringed and ticked; a
* second tap on a source's photo lets go of it, and "Remove picture" leaves
* the place without one. Nothing is sent before the form is saved.
*/
export default function PlaceImagePicker({
photos,
chosen,
onPickUrl,
own,
dialog = false,
t,
}: PlaceImagePickerProps): React.ReactElement | null {
const [hovered, setHovered] = useState<Credit>(null);
const current = own?.currentUrl && !photos.some((p) => p.url === own.currentUrl) ? own.currentUrl : null;
const attached = own?.attached ?? [];
if (!offersPictures(photos, own)) return null;
const shown = hovered ?? creditFor(chosen, photos, attached, own, t);
const removable = !!own && chosen.kind !== 'none';
return (
<>
<div className="grid grid-cols-3 gap-1.5">
{own?.canUpload && (
<UploadTile
own={own}
selected={chosen.kind === 'upload'}
dialog={dialog}
onHover={(on) => setHovered(on ? { kind: 'own', label: t('places.image.own') } : null)}
t={t}
/>
)}
{current && (
<button
type="button"
onClick={() => (chosen.kind === 'current' ? own?.onRemove() : onPickUrl(current))}
aria-pressed={chosen.kind === 'current'}
aria-label={t('places.image.current')}
onMouseEnter={() => setHovered({ kind: 'own', label: t('places.image.current') })}
onMouseLeave={() => setHovered(null)}
className={`${TILE} ${ring(chosen.kind === 'current', dialog)}`}
>
<img src={current} alt="" className="h-full w-full object-cover" />
{chosen.kind === 'current' && <Picked />}
</button>
)}
{attached.map((file) => (
<AttachedTile
key={file.id}
file={file}
selected={chosen.kind === 'attached' && chosen.fileId === file.id}
onPick={() => own?.onPickAttached(file)}
onHover={(on) => setHovered(on ? { kind: 'own', label: file.original_name } : null)}
dialog={dialog}
/>
))}
{photos.map((photo) => (
<PhotoTile
key={photo.key}
photo={photo}
selected={chosen.kind === 'url' && chosen.url === photo.url}
onPick={onPickUrl}
onHover={(on) => setHovered(on ? { kind: 'photo', photo } : null)}
dialog={dialog}
t={t}
/>
))}
</div>
{(shown || removable) && (
<CreditLine shown={shown} onRemove={removable ? own?.onRemove : undefined} dialog={dialog} t={t} />
)}
</>
);
}
/** The tile that takes a picture from the device, or shows the one waiting for the save. */
function UploadTile({
own,
selected,
dialog,
onHover,
t,
}: {
own: PlaceImageOwn;
selected: boolean;
dialog: boolean;
onHover: (on: boolean) => void;
t: TranslationFn;
}): React.ReactElement {
const fileRef = useRef<HTMLInputElement>(null);
const [reading, setReading] = useState(false);
const pick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setReading(true);
try {
own.onUpload(await normalizeImageFile(file));
} finally {
setReading(false);
}
};
const preview = own.pendingPreview;
return (
<>
<Tooltip label={t('places.uploadImage')} placement="top">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={reading}
aria-pressed={selected}
aria-label={t('places.uploadImage')}
className={`${TILE} ${ring(selected, dialog)} ${preview ? '' : 'border border-dashed border-edge bg-surface-card'}`}
onMouseEnter={() => onHover(!!preview)}
onMouseLeave={() => onHover(false)}
>
{preview ? (
<>
<img src={preview} alt="" className="h-full w-full object-cover" />
{selected && <Picked />}
</>
) : (
<span className="flex h-full w-full flex-col items-center justify-center gap-1 px-1 text-center text-caption leading-tight text-content-muted group-hover:text-content">
{reading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" strokeWidth={2} />
)}
{t('places.uploadImage')}
</span>
)}
</button>
</Tooltip>
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" className="hidden" onChange={(e) => void pick(e)} />
</>
);
}
/** Who the chosen picture is from, and the way to leave the place without one. */
function CreditLine({
shown,
onRemove,
dialog,
t,
}: {
shown: Credit;
onRemove?: () => void;
dialog: boolean;
t: TranslationFn;
}): React.ReactElement {
return (
<div
className={`${dialog ? 'mt-2' : 'mt-1.5'} flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint`}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{shown?.kind === 'photo' && <PhotoCredit photo={shown.photo} />}
{shown?.kind === 'own' && <span className="min-w-0 truncate">{shown.label}</span>}
</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="inline-flex flex-none items-center gap-1 rounded-md px-1.5 py-0.5 text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
<ImageOff className="h-3 w-3" />
{t('places.removeImage')}
</button>
)}
</div>
);
}
/** The credit line for the chosen tile: a source's photo, the place's own picture, or nothing to say. */
function creditFor(
chosen: ChosenImage,
photos: PlacePhotoCandidate[],
attached: TripFile[],
own: PlaceImageOwn | undefined,
t: TranslationFn
): Credit {
if (chosen.kind === 'url') {
const photo = photos.find((p) => p.url === chosen.url);
if (photo) return { kind: 'photo', photo };
}
if (chosen.kind === 'current') return { kind: 'own', label: t('places.image.current') };
if (chosen.kind === 'upload') return { kind: 'own', label: t('places.image.own') };
if (chosen.kind === 'attached') {
const file = attached.find((f) => f.id === chosen.fileId);
if (file) return { kind: 'own', label: file.original_name };
}
if (chosen.kind === 'none' && own?.currentUrl) return { kind: 'own', label: t('places.image.none') };
return photos[0] ? { kind: 'photo', photo: photos[0] } : null;
}
function PhotoTile({
photo,
selected,
onPick,
onHover,
dialog,
t,
}: {
photo: PlacePhotoCandidate;
selected: boolean;
onPick: (url: string | null) => void;
onHover: (on: boolean) => void;
dialog: boolean;
t: TranslationFn;
}): React.ReactElement {
const credit = creditOf(photo);
return (
<button
type="button"
onClick={() => onPick(selected ? null : photo.url)}
onMouseEnter={() => onHover(true)}
onMouseLeave={() => onHover(false)}
onFocus={() => onHover(true)}
onBlur={() => onHover(false)}
aria-pressed={selected}
aria-label={`${t('places.details.pickImage')}: ${credit}`}
title={dialog ? undefined : photo.license ? `${credit}, ${photo.license}` : credit}
className={`${TILE} ${ring(selected, dialog)}`}
>
<img
src={photo.url}
alt=""
loading="lazy"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
/>
{selected && <Picked />}
</button>
);
}
/** An image attached to the place, loaded through the authenticated file route. */
function AttachedTile({
file,
selected,
onPick,
onHover,
dialog,
}: {
file: TripFile;
selected: boolean;
onPick: () => void;
onHover: (on: boolean) => void;
dialog: boolean;
}): React.ReactElement {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
let current = true;
placePictureRepo
.attachedImage(file.url)
.then((url) => {
if (current) setSrc(url);
})
.catch(() => {
if (current) setSrc(null);
});
return () => {
current = false;
};
}, [file.url]);
return (
<button
type="button"
onClick={onPick}
onMouseEnter={() => onHover(true)}
onMouseLeave={() => onHover(false)}
onFocus={() => onHover(true)}
onBlur={() => onHover(false)}
aria-pressed={selected}
aria-label={file.original_name}
className={`${TILE} bg-surface-card ${ring(selected, dialog)}`}
>
{src && <img src={src} alt="" className="h-full w-full object-cover" />}
{selected && <Picked />}
</button>
);
}
/**
* Author and licence of the source's photo in play, each linked to its page.
*
* Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one
* without naming its author does not satisfy those terms. When a source hands
* us no author (Google), we say where it came from rather than inventing one.
*/
export function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElement {
const credit = creditOf(photo);
return (
<>
<span className="min-w-0 truncate">
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
</span>
{photo.license && (
<span className="flex-none">
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</span>
)}
</>
);
}
@@ -23,7 +23,6 @@ vi.mock('../../api/client', async (importOriginal) => {
mapsApi: {
details: vi.fn().mockResolvedValue({ place: null }),
placePhotoCredit: vi.fn().mockResolvedValue({ credit: null }),
placeEnrichment: vi.fn().mockResolvedValue({ photos: [], facts: [], description: null }),
},
};
});
@@ -55,7 +54,6 @@ beforeAll(() => {
import PlaceInspector from './PlaceInspector';
import { mapsApi } from '../../api/client';
import { __clearEnrichmentCacheForTests } from './placeEnrichment';
// ── Shared fixtures ───────────────────────────────────────────────────────────
@@ -102,8 +100,6 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } });
vi.mocked(mapsApi.details).mockResolvedValue({ place: null });
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue({ photos: [], facts: [], description: null } as never);
__clearEnrichmentCacheForTests();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -974,21 +970,18 @@ describe('PlaceInspector', () => {
}
});
// ── The avatar opens the place's photos (#1136) ─────────────────────────────
// ── Custom thumbnail upload (#1136) ──────────────────────────────────────────
it('FE-PLANNER-INSPECTOR-049: the avatar opens the photos and offers no upload, the picture is changed in the editor', () => {
render(<PlaceInspector {...defaultProps} />);
expect(screen.getByRole('button', { name: 'Show photos' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
it('FE-PLANNER-INSPECTOR-049: onUploadImage in trip mode renders the upload-capable avatar', () => {
render(<PlaceInspector {...defaultProps} onUploadImage={vi.fn()} />);
// The place carries no image yet, so the avatar offers "Upload image".
expect(screen.getByRole('button', { name: 'Upload image' })).toBeTruthy();
});
it('FE-PLANNER-INSPECTOR-050: with no picture and nobody to ask for one the avatar stays a plain picture', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
it('FE-PLANNER-INSPECTOR-050: without onUploadImage the avatar has no upload control', () => {
render(<PlaceInspector {...defaultProps} />);
expect(screen.queryByRole('button', { name: 'Show photos' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
});
// ── Track colour (#776) ──────────────────────────────────────────────────────
@@ -1422,23 +1415,15 @@ describe('PlaceInspector', () => {
expect(document.querySelector('iframe[src*="tide-widget"]')).toBeNull();
});
// ── The avatar's gallery (#1136) ────────────────────────────────────────────
// ── Custom thumbnail callbacks (#1136) ───────────────────────────────────────
it('FE-PLANNER-INSPECTOR-088: the custom image opens at once in the gallery, and the avatar has no remove control', async () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
it('FE-PLANNER-INSPECTOR-088: removing the custom image clears image_url through onUpdatePlace', async () => {
const onUpdatePlace = vi.fn();
const onUploadImage = vi.fn(async () => {});
const withImage = buildPlace({ id: 706, name: 'Pictured', image_url: '/uploads/places/x.jpg' });
render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} />);
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }));
const gallery = await screen.findByRole('dialog', { name: 'Show photos' });
expect(gallery.querySelector('img')).toHaveAttribute('src', '/uploads/places/x.jpg');
expect(mapsApi.placeEnrichment).not.toHaveBeenCalled();
expect(onUpdatePlace).not.toHaveBeenCalled();
fireEvent.click(within(gallery).getByRole('button', { name: 'Close' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Show photos' })).toBeNull());
render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} onUploadImage={onUploadImage} />);
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }));
expect(onUpdatePlace).toHaveBeenCalledWith(706, { image_url: null });
});
it('FE-PLANNER-INSPECTOR-090: opening hours are read for the selected day, not for today', async () => {
@@ -1560,28 +1545,13 @@ describe('PlaceInspector', () => {
}
});
it("FE-PLANNER-INSPECTOR-089: the avatar asks the sources for the place's photos on click and shows them credited", async () => {
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue({
photos: [{
key: 'gp~p0', url: '/api/maps/place-photo/gp~p0/bytes', attribution: 'Alice', license: 'CC BY 4.0',
licenseUrl: null, sourceUrl: 'https://commons.wikimedia.org/wiki/File:T.jpg', source: 'wikimedia',
}],
facts: [],
description: null,
} as never);
render(<PlaceInspector {...defaultProps} />);
// Nothing is asked before the click: selecting a place is not worth the provider fan-out.
expect(mapsApi.placeEnrichment).not.toHaveBeenCalled();
expect(document.querySelector('input[type="file"][accept*="image"]')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }));
const gallery = await screen.findByRole('dialog', { name: 'Show photos' });
// The query, then the signal the repo passes on (placePictureRepo.ts).
expect(vi.mocked(mapsApi.placeEnrichment).mock.calls[0]?.[0]).toMatchObject({ lat: place.lat, lng: place.lng, name: 'Eiffel Tower' });
expect(within(gallery).getByRole('img')).toHaveAttribute('src', '/api/maps/place-photo/gp~p0/bytes');
expect(within(gallery).getByRole('link', { name: 'Alice, CC BY 4.0' })).toHaveAttribute(
'href', 'https://commons.wikimedia.org/wiki/File:T.jpg',
);
it('FE-PLANNER-INSPECTOR-089: picking a file hands it to onUploadImage', async () => {
const onUploadImage = vi.fn(async () => {});
render(<PlaceInspector {...defaultProps} onUploadImage={onUploadImage} />);
const input = document.querySelector('input[accept*="image"]') as HTMLInputElement;
const file = new File(['x'], 'thumb.png', { type: 'image/png' });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(onUploadImage).toHaveBeenCalledWith(place.id, file));
});
it('FE-PLANNER-INSPECTOR-098: deselecting and reselecting a place survives a rerender', async () => {
@@ -9,7 +9,7 @@ import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Clock, MapPin, ExternalLink, Phone, Mail, Banknote, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Star, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react'
import { hoursLines, periodsFromWeek } from './placeHours'
import PlaceAvatar from '../shared/PlaceAvatar'
import PlacePhotoGallery from './PlacePhotoGallery'
import PlaceAvatarUpload, { pickableImages } from '../shared/PlaceAvatarUpload'
import { BlurredCode } from '../shared/BookingCode'
import PlaceRating from '../shared/StarRating'
import TrackColorPicker from '../shared/TrackColorPicker'
@@ -186,6 +186,10 @@ interface PlaceInspectorProps {
tripMembers?: TripMember[]
onSetParticipants?: (assignmentId: number, dayId: number, participantIds: number[]) => void
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
/** Upload a custom thumbnail (#1136); enables the click-to-change avatar in trip mode. */
onUploadImage?: (placeId: number, file: File) => Promise<void>
/** Takes a picture already attached to the place as its image (#1242). */
onImageFromFile?: (placeId: number, fileId: number) => Promise<void>
/** Cast/clear the current user's star vote (#1435); enables the rating row. */
onRate?: (placeId: number, rating: number | null) => Promise<void> | void
leftWidth?: number
@@ -211,7 +215,7 @@ export default function PlaceInspector({
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking,
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onRate,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onImageFromFile, onRate,
leftWidth = 0, rightWidth = 0, clearMapPill = false,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) {
@@ -388,6 +392,9 @@ export default function PlaceInspector({
editingName={editingName} nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue}
commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit}
onUpdatePlace={onUpdatePlace}
onUploadImage={mode === 'trip' && onUpdatePlace ? onUploadImage : undefined}
attachedImages={pickableImages(placeFiles)}
onImageFromFile={mode === 'trip' && onUpdatePlace ? onImageFromFile : undefined}
onClose={onClose} />
{/* The body scrolls inside the capped card; each block keeps its natural height (#1195). */}
@@ -591,17 +598,19 @@ interface InspectorHeadProps {
handleNameKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
startNameEdit: () => void
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
onUploadImage?: (placeId: number, file: File) => Promise<void>
attachedImages: TripFile[]
onImageFromFile?: (placeId: number, fileId: number) => Promise<void>
onClose: () => void
}
/**
* The head band: the avatar with its open/closed ring and photo credit, the
* name (a double-click renames it), the address, and the facts as pills. The
* avatar opens the place's photos; its picture is changed in the editor.
* name (a double-click renames it), the address, and the facts as pills.
*/
function InspectorHead({ place, category, openNow, phone, email, rating, ratingCount, price, time, editingName, nameInputRef,
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onClose }: InspectorHeadProps) {
const { t, locale, language } = useTranslation()
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onUploadImage, attachedImages, onImageFromFile, onClose }: InspectorHeadProps) {
const { t, locale } = useTranslation()
const ring = openNow === true ? 'bg-success' : openNow === false ? 'bg-danger' : 'bg-surface-card shadow-sm'
const hasCoords = !!(place.lat && place.lng)
const hasPills = openNow !== null || !!category || !!time || rating != null || !!price || place.source === 'dawarich' || !!phone || !!email || hasCoords
@@ -610,9 +619,13 @@ function InspectorHead({ place, category, openNow, phone, email, rating, ratingC
<div className="flex items-start gap-3.5">
<div className="flex w-[57px] flex-none flex-col items-center">
<div className={`rounded-full p-[2.5px] ${ring}`}>
<PlacePhotoGallery place={place} language={language} label={t('places.openPhotos')} className="rounded-full">
<PlaceAvatar place={place} category={category} size={52} />
</PlacePhotoGallery>
{onUploadImage
? <PlaceAvatarUpload place={place} category={category} size={52}
onUpload={(file: File) => onUploadImage(place.id, file)}
onRemove={() => onUpdatePlace(place.id, { image_url: null })}
attachedImages={attachedImages}
onPickAttached={onImageFromFile ? (fileId: number) => onImageFromFile(place.id, fileId) : undefined} />
: <PlaceAvatar place={place} category={category} size={52} />}
</div>
<PhotoCredit imageUrl={place.image_url} />
</div>
@@ -1,106 +0,0 @@
// FE-PLANNER-PHOTOGALLERY-001 to FE-PLANNER-PHOTOGALLERY-003: the place's picture as the
// way into its photos, on the desktop inspector and the phone sheet alike.
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import PlacePhotoGallery from './PlacePhotoGallery';
import { __clearEnrichmentCacheForTests } from './placeEnrichment';
const placeEnrichment = vi.fn();
// Partial: the store registry behind resetAllStores needs the real default client.
vi.mock('../../api/client', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/client')>()),
mapsApi: { placeEnrichment: (...a: unknown[]) => placeEnrichment(...a) },
}));
vi.mock('../Journey/VideoPlayerLazy', () => ({ default: () => null }));
const PLACE = buildPlace({ id: 3, name: 'Tower', lat: 48.85, lng: 2.29, image_url: '/uploads/places/tower.jpg' });
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { placesEnrichEnabled: true });
placeEnrichment.mockReset();
sessionStorage.clear();
__clearEnrichmentCacheForTests();
});
describe('PlacePhotoGallery', () => {
it('FE-PLANNER-PHOTOGALLERY-001: with nothing to open the picture stays a plain picture', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
render(
<PlacePhotoGallery place={{ ...PLACE, image_url: null }} language="en" label="Show photos">
<span data-testid="avatar" />
</PlacePhotoGallery>
);
expect(screen.getByTestId('avatar')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('FE-PLANNER-PHOTOGALLERY-002: the trigger is a labelled button that says it is busy while the sources answer', async () => {
let settle: (v: MapsPlaceEnrichmentResult) => void = () => {};
placeEnrichment.mockReturnValue(new Promise<MapsPlaceEnrichmentResult>((r) => (settle = r)));
render(
<PlacePhotoGallery
place={{ ...PLACE, image_url: null }}
language="en"
label="Show photos"
className="rounded-full"
>
<span data-testid="avatar" />
</PlacePhotoGallery>
);
const trigger = screen.getByRole('button', { name: 'Show photos' });
expect(trigger).toHaveClass('rounded-full');
expect(trigger).toContainElement(screen.getByTestId('avatar'));
expect(trigger).toHaveAttribute('aria-busy', 'false');
fireEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-busy', 'true');
expect(trigger).toHaveClass('opacity-70');
await act(async () =>
settle({
photos: [
{
key: 'k0',
url: '/api/maps/place-photo/k0/bytes',
attribution: 'Alice',
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'wikimedia',
},
],
facts: [],
description: null,
} as unknown as MapsPlaceEnrichmentResult)
);
expect(trigger).toHaveAttribute('aria-busy', 'false');
const gallery = screen.getByRole('dialog', { name: 'Show photos' });
expect(within(gallery).getByRole('img', { name: 'Alice' })).toHaveAttribute(
'src',
'/api/maps/place-photo/k0/bytes'
);
});
it('FE-PLANNER-PHOTOGALLERY-003: the gallery opens on the body, over everything, and closes again', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const { container } = render(
<PlacePhotoGallery place={PLACE} language="en" label="Show photos">
<span data-testid="avatar" />
</PlacePhotoGallery>
);
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }));
const gallery = screen.getByRole('dialog', { name: 'Show photos' });
expect(gallery.parentElement).toBe(document.body);
expect(container).not.toContainElement(gallery);
expect(gallery.querySelector('img')).toHaveAttribute('src', '/uploads/places/tower.jpg');
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'Show photos' })).not.toBeInTheDocument();
});
});
@@ -1,60 +0,0 @@
import { Loader2 } from 'lucide-react';
import React from 'react';
import { createPortal } from 'react-dom';
import type { Place } from '../../types';
import PhotoLightbox from '../Journey/PhotoLightbox';
import { Tooltip } from '../shared/Tooltip';
import { usePlaceGallery } from './usePlaceGallery';
interface PlacePhotoGalleryProps {
place: Place;
language: string;
/** What opens the gallery: the place's picture, or what stands in for it. */
children: React.ReactNode;
/** The open gallery's label, and the trigger's tooltip. */
label: string;
/** The trigger's shape, so its focus ring follows the picture (round on desktop, a rounded tile on the phone). */
className?: string;
}
/**
* The place's picture as the way into its photos: a click opens a full screen
* gallery of its own picture and what its sources know, each credited. While
* there is nothing to show the picture stays a plain picture. The lightbox is
* portalled to <body>: the inspector and the phone sheet sit inside
* transformed containers, where a fixed overlay would be trapped.
*/
export default function PlacePhotoGallery({
place,
language,
children,
label,
className = '',
}: PlacePhotoGalleryProps): React.ReactElement {
const { photos, open, close, isOpen, loading, canOpen } = usePlaceGallery(place, language);
if (!canOpen) return <>{children}</>;
return (
<>
<Tooltip label={label} placement="top">
<button
type="button"
onClick={open}
aria-label={label}
aria-busy={loading}
className={`relative block cursor-zoom-in transition-opacity focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${loading ? 'opacity-70' : ''} ${className}`}
>
{children}
{loading && (
<span className="absolute inset-0 grid place-items-center">
<Loader2 className="h-4 w-4 animate-spin text-content" />
</span>
)}
</button>
</Tooltip>
{isOpen &&
photos.length > 0 &&
createPortal(<PhotoLightbox photos={photos} onClose={close} label={label} />, document.body)}
</>
);
}

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