Compare commits

..

3 Commits

Author SHA1 Message Date
Maurice 473132fc07 fix(studio): a slider commits the value it was moved to, and the paint render test uses a real shape
- The opacity and paint sliders remember the value they were last moved to and commit that when let go, instead of reading the controlled input, which still shows the old value until the parent renders.
- The render test's old-fade shape is star-5; star is not a shape, so the whole fixture failed to parse.
2026-10-23 21:07:04 +02:00
Maurice e7a5316990 test(studio): a raced Studio save carries its revision, as the Studio's own saves do 2026-10-23 20:58:19 +02:00
Maurice 670c841866 feat(studio): paint shapes and pages with gradients, alpha and twelve patterns
- A shape and a page take a paint: one colour with its own alpha, a linear or
  radial gradient of two to six stops, or one of twelve patterns (checks,
  gingham, stripes, pinstripes, dots, polka dots, zigzag, waves, grid, scales,
  sunburst, confetti). fill, gradient and background keep the colour that
  stands for the paint, so an older reader still draws something close, and a
  plain colour or the old cover fade is stored in the old fields alone.
- Gradients render as SVG in user space with ids per mounted copy, so the two
  halves of a spread in page mode never share a defs id; patterns are drawn
  out as geometry clipped to the outline and capped at 2000 primitives.
- The new paint picker has none, solid, gradient and pattern tabs, a stop bar
  to click, drag and pull stops off, an angle dial, a radial centre pad and
  pattern tiles drawn by the page's own generator.
- Every swatch row gains the book's own colours, colours read from the
  page's photos, recent colours and, where it applies, an opacity slider.
- With nothing selected the inspector sets the page background, gives it to
  every page of its kind, and lays a photo under the whole page.
- The assistant's background operation takes a paint as well as a colour,
  style and add set a shape's paint, and describe_book_page reads both.
- BOOK_SCHEMA_REV goes to 2.
2026-10-23 20:10:36 +02:00
1297 changed files with 6560 additions and 90631 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** `server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain. (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 }} {{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }} TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }} {{- 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 }} {{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }} AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }} {{- 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 # nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on. # here. Defaults to on.
# TREK_PLACES_URL: "" # 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 # 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. # configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com. # 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: [] };
}
+2 -2
View File
@@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request,
// ── Studio ──────────────────────────────────────────────────────────────────── // ── Studio ────────────────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.st-rail') 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 sidePanel = (page: Page) => page.locator('.st-panel.st-side')
const inspector = (page: Page) => page.locator('.st-inspector') const inspector = (page: Page) => page.locator('.st-inspector')
const studioMenu = (page: Page) => page.locator('.st-menu').last() const studioMenu = (page: Page) => page.locator('.st-menu').last()
@@ -1431,7 +1431,7 @@ const SCRIPTS: Record<string, GuideScript> = {
}, },
{ {
prepare: async p => { 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 expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
await settle(p) await settle(p)
}, },
+1 -28
View File
@@ -47,41 +47,14 @@
}, },
"dependencies": { "dependencies": {
"@assistant-ui/react": "0.15.25", "@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/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/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^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/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0", "@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^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/playfair-display": "^5.3.0",
"@fontsource/poppins": "^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", "@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4", "@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
@@ -108,7 +81,7 @@
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tz-lookup": "^6.1.25", "tz-lookup": "^6.1.25",
"zod": "^4.6.7", "zod": "^4.6.5",
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
+12 -12
View File
@@ -1,26 +1,26 @@
{ {
"src": { "src": {
"@typescript-eslint/no-non-null-assertion": 385, "@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 849, "@typescript-eslint/no-misused-promises": 854,
"no-console": 88, "no-console": 88,
"complexity": 222, "complexity": 226,
"@typescript-eslint/no-explicit-any": 146, "@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130, "trek/no-swallowed-catch": 131,
"max-params": 32, "max-params": 33,
"trek/store-without-selector": 67, "trek/store-without-selector": 67,
"react-hooks/refs": 556, "react-hooks/refs": 557,
"react-hooks/set-state-in-effect": 194, "react-hooks/set-state-in-effect": 195,
"@typescript-eslint/no-floating-promises": 165, "@typescript-eslint/no-floating-promises": 165,
"react-hooks/exhaustive-deps": 116, "react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124, "@typescript-eslint/no-unused-vars": 126,
"react-hooks/immutability": 28, "react-hooks/immutability": 28,
"max-lines-per-function": 130, "max-lines-per-function": 132,
"react-hooks/static-components": 78, "react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 12, "react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7, "no-useless-assignment": 7,
"no-empty": 19, "no-empty": 19,
"trek/no-window-globals": 41, "trek/no-window-globals": 41,
"react-refresh/only-export-components": 45, "react-refresh/only-export-components": 46,
"unused-disable-directive": 6, "unused-disable-directive": 6,
"max-depth": 21, "max-depth": 21,
"react-hooks/void-use-memo": 1, "react-hooks/void-use-memo": 1,
@@ -34,8 +34,8 @@
"react-hooks/exhaustive-deps": 46 "react-hooks/exhaustive-deps": 46
}, },
"tests": { "tests": {
"@typescript-eslint/no-explicit-any": 490, "@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1931, "@typescript-eslint/no-non-null-assertion": 1932,
"@typescript-eslint/no-misused-promises": 22, "@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51, "@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33, "@typescript-eslint/no-floating-promises": 33,
+1 -9
View File
@@ -69,15 +69,6 @@
"desktop": ["src/components/Assistant/AssistantWindow.tsx"], "desktop": ["src/components/Assistant/AssistantWindow.tsx"],
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.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", "feature": "Assistant hints",
"hooks": ["src/components/Assistant/useAssistantHints.ts"], "hooks": ["src/components/Assistant/useAssistantHints.ts"],
@@ -601,6 +592,7 @@
"hooks": ["src/components/Planner/usePlaceActions.ts"], "hooks": ["src/components/Planner/usePlaceActions.ts"],
"desktop": [ "desktop": [
"src/components/Planner/PlaceInspector.tsx", "src/components/Planner/PlaceInspector.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/pages/tripPlanner/useTripPlannerPage.ts" "src/pages/tripPlanner/useTripPlannerPage.ts"
], ],
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"] "mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
+2
View File
@@ -1123,6 +1123,8 @@
"src/components/shared/PasswordChecklist.tsx", "src/components/shared/PasswordChecklist.tsx",
"src/components/shared/PlaceAvatar.test.tsx", "src/components/shared/PlaceAvatar.test.tsx",
"src/components/shared/PlaceAvatar.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.test.tsx",
"src/components/shared/PluginIcon.tsx", "src/components/shared/PluginIcon.tsx",
"src/components/shared/PublicLanguagePicker.test.tsx", "src/components/shared/PublicLanguagePicker.test.tsx",
+5 -7
View File
@@ -117,6 +117,11 @@ export const DYNAMIC_ALLOWED = [
template: 'settings.${mode}', template: 'settings.${mode}',
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist", 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', file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
template: 'reservations.${s}', template: 'reservations.${s}',
@@ -139,13 +144,6 @@ export const DYNAMIC_ALLOWED = [
'memories.* key in en', '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_:-]+)+$/; const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
+1 -1
View File
@@ -26,7 +26,7 @@
"packing.ts": 6, "packing.ts": 6,
"pdf.ts": 1, "pdf.ts": 1,
"perm.ts": 4, "perm.ts": 4,
"places.ts": 9, "places.ts": 10,
"planner.ts": 53, "planner.ts": 53,
"reservations.ts": 21, "reservations.ts": 21,
"roadtrip.ts": 36, "roadtrip.ts": 36,
+1 -1
View File
@@ -38,8 +38,8 @@
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1, "src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1, "src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
"src/components/Studio/StudioShell.tsx": 1, "src/components/Studio/StudioShell.tsx": 1,
"src/components/Studio/StudioSidebar.tsx": 1,
"src/components/Studio/TravelElements.tsx": 1, "src/components/Studio/TravelElements.tsx": 1,
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
"src/components/Todo/useTodoList.ts": 1, "src/components/Todo/useTodoList.ts": 1,
"src/components/shared/ConfirmDialog.tsx": 1, "src/components/shared/ConfirmDialog.tsx": 1,
"src/components/shared/CountryFlag.tsx": 1, "src/components/shared/CountryFlag.tsx": 1,
+1
View File
@@ -116,6 +116,7 @@
"src/components/Vacay/useVacayCalendarLogic.ts", "src/components/Vacay/useVacayCalendarLogic.ts",
"src/components/Vacay/useVacaySettings.ts", "src/components/Vacay/useVacaySettings.ts",
"src/components/Vacay/useVacayUserPicker.ts", "src/components/Vacay/useVacayUserPicker.ts",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/hooks/useAirtrailConnection.ts", "src/hooks/useAirtrailConnection.ts",
"src/hooks/useDawarichConnection.ts", "src/hooks/useDawarichConnection.ts",
"src/hooks/useDawarichSuggestions.ts", "src/hooks/useDawarichSuggestions.ts",
+6 -3
View File
@@ -18,6 +18,7 @@
"components/Journey/JourneyMap.tsx": 1, "components/Journey/JourneyMap.tsx": 1,
"components/Journey/JourneyMapGL.tsx": 2, "components/Journey/JourneyMapGL.tsx": 2,
"components/Journey/MobileEntryView.tsx": 1, "components/Journey/MobileEntryView.tsx": 1,
"components/Journey/PhotoLightbox.tsx": 3,
"components/Journey/journeyPhotoLayer.ts": 1, "components/Journey/journeyPhotoLayer.ts": 1,
"components/Layout/Navbar.tsx": 1, "components/Layout/Navbar.tsx": 1,
"components/Layout/OfflineBanner.tsx": 1, "components/Layout/OfflineBanner.tsx": 1,
@@ -51,12 +52,13 @@
"components/Studio/BookSheetsView.tsx": 3, "components/Studio/BookSheetsView.tsx": 3,
"components/Studio/PageNumbers.tsx": 1, "components/Studio/PageNumbers.tsx": 1,
"components/Studio/PeerCursors.tsx": 1, "components/Studio/PeerCursors.tsx": 1,
"components/Studio/PhotoView.tsx": 4, "components/Studio/SpreadView.tsx": 8,
"components/Studio/SpreadView.tsx": 4, "components/Studio/StudioCanvas.tsx": 22,
"components/Studio/StudioCanvas.tsx": 12,
"components/Studio/StudioElementsPanel.tsx": 1, "components/Studio/StudioElementsPanel.tsx": 1,
"components/Studio/StudioExport.tsx": 1, "components/Studio/StudioExport.tsx": 1,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioSavedLayouts.tsx": 1, "components/Studio/StudioSavedLayouts.tsx": 1,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 4, "components/Studio/TravelElements.tsx": 4,
"components/Studio/TravelPreview.tsx": 1, "components/Studio/TravelPreview.tsx": 1,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3, "components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
@@ -71,6 +73,7 @@
"components/shared/DetailShell.tsx": 1, "components/shared/DetailShell.tsx": 1,
"components/shared/ElevationProfile.tsx": 2, "components/shared/ElevationProfile.tsx": 2,
"components/shared/NavigationMenu.tsx": 2, "components/shared/NavigationMenu.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 1, "components/shared/PublicLanguagePicker.tsx": 1,
"components/shared/SlidingTabs.tsx": 3, "components/shared/SlidingTabs.tsx": 3,
"components/shared/StarRating.tsx": 3, "components/shared/StarRating.tsx": 3,
+2 -5
View File
@@ -1,7 +1,4 @@
{ {
"components/Studio/LineHandles.tsx": 1, "pages/tripPlanner/PlanMapStage.tsx": 4,
"components/Studio/StudioSelectionChrome.tsx": 2, "mobile/components/trek3d/MascotClip.tsx": 1
"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/MapView.tsx": 1399,
"src/components/Map/MapViewGL.test.tsx": 2920, "src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2290, "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/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333, "src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349, "src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2953, "src/components/Planner/DayPlanSidebar.tsx": 2953,
"src/components/Planner/PlaceFormModal.test.tsx": 2836, "src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1157, "src/components/Planner/PlaceFormModal.tsx": 1193,
"src/components/Planner/PlaceInspector.tsx": 1290, "src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1738, "src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/countryShapes.ts": 2595, "src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636, "src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507, "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030, "src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348, "src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1128, "src/pages/TripPlannerPage.tsx": 1131,
"src/pages/atlas/useAtlas.ts": 1211, "src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270, "src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 2009 "src/styles/studio.css": 2087
} }
+4 -1
View File
@@ -51,7 +51,7 @@
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1, "src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
"src/components/Planner/PlaceFormModal.test.tsx": 3, "src/components/Planner/PlaceFormModal.test.tsx": 3,
"src/components/Planner/PlaceFormModal.tsx": 1, "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/PlacesBulkCategoryModal.test.tsx": 1,
"src/components/Planner/PlacesSidebar.tsx": 1, "src/components/Planner/PlacesSidebar.tsx": 1,
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8, "src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
@@ -82,6 +82,8 @@
"src/components/Settings/NotificationsTab.test.tsx": 1, "src/components/Settings/NotificationsTab.test.tsx": 1,
"src/components/Settings/PasskeysSection.tsx": 1, "src/components/Settings/PasskeysSection.tsx": 1,
"src/components/Settings/PhotoProvidersSection.test.tsx": 2, "src/components/Settings/PhotoProvidersSection.test.tsx": 2,
"src/components/Studio/StudioCanvas.tsx": 1,
"src/components/Studio/StudioInspector.tsx": 1,
"src/components/Todo/todoListModel.ts": 1, "src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2, "src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2, "src/components/Tours/planner/useTourPlanner.ts": 2,
@@ -96,6 +98,7 @@
"src/components/shared/CustomSelect.test.tsx": 1, "src/components/shared/CustomSelect.test.tsx": 1,
"src/components/shared/PlaceAvatar.test.tsx": 2, "src/components/shared/PlaceAvatar.test.tsx": 2,
"src/components/shared/PlaceAvatar.tsx": 1, "src/components/shared/PlaceAvatar.tsx": 1,
"src/components/shared/PlaceAvatarUpload.test.tsx": 1,
"src/hooks/useDawarichSuggestions.test.ts": 1, "src/hooks/useDawarichSuggestions.test.ts": 1,
"src/mobile/screens/admin/MAdminAddonManager.tsx": 2, "src/mobile/screens/admin/MAdminAddonManager.tsx": 2,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6, "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6,
+5 -4
View File
@@ -100,13 +100,13 @@
"components/Roadtrip/serviceMarker.ts": 1, "components/Roadtrip/serviceMarker.ts": 1,
"components/Settings/useAppearanceEditor.ts": 5, "components/Settings/useAppearanceEditor.ts": 5,
"components/Studio/BookSheetsView.tsx": 2, "components/Studio/BookSheetsView.tsx": 2,
"components/Studio/PhotoView.tsx": 2, "components/Studio/SpreadView.tsx": 3,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 8, "components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioShell.tsx": 2, "components/Studio/StudioShell.tsx": 2,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 5, "components/Studio/TravelElements.tsx": 5,
"components/Studio/printSheets.ts": 5, "components/Studio/printSheets.ts": 5,
"components/Studio/sidebar/contentDrops.ts": 2,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4, "components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
"components/SystemNotices/SystemNoticeBanner.tsx": 28, "components/SystemNotices/SystemNoticeBanner.tsx": 28,
"components/SystemNotices/SystemNoticeModal.tsx": 107, "components/SystemNotices/SystemNoticeModal.tsx": 107,
@@ -132,6 +132,7 @@
"components/shared/Modal.tsx": 3, "components/shared/Modal.tsx": 3,
"components/shared/NavigationMenu.tsx": 1, "components/shared/NavigationMenu.tsx": 1,
"components/shared/NoteColorPicker.tsx": 2, "components/shared/NoteColorPicker.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 3, "components/shared/PublicLanguagePicker.tsx": 3,
"components/shared/Skeleton.tsx": 2, "components/shared/Skeleton.tsx": 2,
"components/shared/Spinner.tsx": 2, "components/shared/Spinner.tsx": 2,
@@ -251,7 +252,7 @@
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3, "mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1, "mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2, "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/MReservationSheet.tsx": 2,
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9, "mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12, "mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
+1 -2
View File
@@ -40,8 +40,7 @@
"components/Settings/AppearanceSettingsTab.tsx": 1, "components/Settings/AppearanceSettingsTab.tsx": 1,
"components/Settings/PasskeysSection.tsx": 1, "components/Settings/PasskeysSection.tsx": 1,
"components/Studio/BookSheetsView.tsx": 1, "components/Studio/BookSheetsView.tsx": 1,
"components/Studio/PhotoView.tsx": 1, "components/Studio/SpreadView.tsx": 2,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 2, "components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1, "components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 6, "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')) 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. // 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 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 // 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 // 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 && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)} {!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
{!hideAuthedWidgets && keepingTour && <ErrorBoundary boundaryId="widget:keep-tour" fallback={null}><Suspense fallback={null}><TourSaveToCollectionHost /></Suspense></ErrorBoundary>} {!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. */} {/* The phone has its own assistant screen inside the trip. */}
{!hideAuthedWidgets && isAuthenticated && ( {!hideAuthedWidgets && isAuthenticated && !isPhone && (
<ErrorBoundary boundaryId="widget:assistant" fallback={null}> <ErrorBoundary boundaryId="widget:assistant" fallback={null}>
<Suspense fallback={null}>{isPhone ? <MAssistantHomeScreen /> : <AssistantHost />}</Suspense> <Suspense fallback={null}><AssistantHost /></Suspense>
</ErrorBoundary> </ErrorBoundary>
)} )}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></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); await assistantApi.stop(journey);
expect(hits).toEqual(['threads', 'undo', 'stop']); 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 AssistantChatRequest,
type AssistantDecisionsRequest, type AssistantDecisionsRequest,
type AssistantErrorCode, type AssistantErrorCode,
type AssistantInsertFoundPlaceRequest,
type AssistantRenderUploadResponse, type AssistantRenderUploadResponse,
type AssistantScope, type AssistantScope,
type AssistantShareRequest, 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 * 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 * scope, which also reaches a journey's chat in TREK Studio. Threads, turns,
* outside any one trip. Threads, turns, decisions, undo and stop exist under * decisions, undo and stop exist under both; files, found places, proposals
* all three; files, found places, proposals and sharing belong to a trip and * and sharing belong to a trip and take its id.
* take its id.
*/ */
export type AssistantTarget = number | AssistantScope; export type AssistantTarget = number | AssistantScope;
function scopeBase(target: AssistantTarget): string { function scopeBase(target: AssistantTarget): string {
if (typeof target === 'number') return tripBase(target); if (typeof target === 'number') return tripBase(target);
if (target.kind === 'home') return '/assistant';
return target.kind === 'trip' ? tripBase(target.tripId) : `/journeys/${target.journeyId}/assistant`; return target.kind === 'trip' ? tripBase(target.tripId) : `/journeys/${target.journeyId}/assistant`;
} }
@@ -146,13 +143,6 @@ export const assistantApi = {
body: AssistantAddFoundPlaceRequest body: AssistantAddFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> => ): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data), 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. // A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
takeProposal: ( takeProposal: (
tripId: number, tripId: number,
@@ -3,7 +3,6 @@ import { ArrowUp, CalendarDays, MapPin, Paperclip, Square, X } from 'lucide-reac
import { type ReactNode, useEffect, useRef } from 'react'; import { type ReactNode, useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { chatOf, useAssistantStore } from '../../store/assistantStore';
import PlaceAvatar from '../shared/PlaceAvatar'; import PlaceAvatar from '../shared/PlaceAvatar';
import Tooltip from '../shared/Tooltip'; 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 empty = useAuiState((s) => s.composer.isEmpty);
const { take, busy, accept, canAttach } = useAssistantAttach(tripId); const { take, busy, accept, canAttach } = useAssistantAttach(tripId);
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
// The home chat is about no one trip, so its placeholder names none. const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
const fallback =
tripId === HOME_CHAT_KEY
? t('assistant.home.placeholder')
: t('assistant.composer.placeholder', { trip: tripTitle });
const placeholder = useTypewriterPlaceholder( const placeholder = useTypewriterPlaceholder(
suggestions.map((s) => s.prompt), suggestions.map((s) => s.prompt),
fallback, fallback,
@@ -1,17 +1,5 @@
import { import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
Ellipsis, import { type HTMLAttributes, type MouseEvent, useState } from 'react';
History,
House,
Lightbulb,
LightbulbOff,
Luggage,
NotebookPen,
Plus,
Settings,
Trash2,
X,
} from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useRef, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
@@ -24,9 +12,7 @@ import Tooltip from '../shared/Tooltip';
import { PopoverListPanel } from '../shared/dialogParts'; import { PopoverListPanel } from '../shared/dialogParts';
import { usePopoverList } from '../shared/usePopoverList'; import { usePopoverList } from '../shared/usePopoverList';
import AssistantMemoryDialog from './AssistantMemoryDialog'; import AssistantMemoryDialog from './AssistantMemoryDialog';
import AssistantTripPicker from './AssistantTripPicker';
import { AI_SETTINGS_PATH } from './assistantParts'; import { AI_SETTINGS_PATH } from './assistantParts';
import type { AssistantChatTrip } from './assistantViewContext';
import { useAssistantHintsSetting } from './useAssistantHints'; import { useAssistantHintsSetting } from './useAssistantHints';
import { useAssistantLiveStep } from './useAssistantLiveStep'; import { useAssistantLiveStep } from './useAssistantLiveStep';
import type { useAssistantThread } from './useAssistantThread'; import type { useAssistantThread } from './useAssistantThread';
@@ -43,16 +29,6 @@ interface AssistantHeaderProps {
onClose: () => void; onClose: () => void;
/** Moves the window: spread on the title, which is the part of the header to grab. */ /** Moves the window: spread on the title, which is the part of the header to grab. */
dragProps: HTMLAttributes<HTMLElement>; 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; * 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. * 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({ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
tripId,
thread,
tripTitle,
onClose,
dragProps,
tripSwitch,
}: AssistantHeaderProps) {
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const live = useAssistantLiveStep(tripId); const live = useAssistantLiveStep(tripId);
const memory = useTripMemory(tripId); const memory = useTripMemory(tripId);
@@ -79,7 +48,6 @@ export default function AssistantHeader({
const [memoryOpen, setMemoryOpen] = useState(false); const [memoryOpen, setMemoryOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const menu = useContextMenu(); const menu = useContextMenu();
const moreRef = useRef<HTMLButtonElement>(null);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const threads = thread.threads ?? []; const threads = thread.threads ?? [];
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1); const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
@@ -96,14 +64,6 @@ export default function AssistantHeader({
run(); 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) => const openMenu = (e: MouseEvent) =>
menu.open( menu.open(
e, e,
@@ -162,7 +122,6 @@ export default function AssistantHeader({
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}
> >
{tripSwitch && <TripSwitchButton {...tripSwitch} />}
{tripChat && ( {tripChat && (
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}> <Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
<button <button
@@ -196,12 +155,10 @@ export default function AssistantHeader({
</Tooltip> </Tooltip>
<Tooltip label={t('assistant.window.menu')}> <Tooltip label={t('assistant.window.menu')}>
<button <button
ref={moreRef}
type="button" type="button"
onClick={openMenu} onClick={openMenu}
aria-label={t('assistant.window.menu')} aria-label={t('assistant.window.menu')}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={menu.menu !== null}
className={ICON_BUTTON} className={ICON_BUTTON}
> >
<Ellipsis size={14} aria-hidden="true" /> <Ellipsis size={14} aria-hidden="true" />
@@ -229,7 +186,6 @@ export default function AssistantHeader({
icon={null} icon={null}
label={th.title} label={th.title}
active={th.id === thread.threadId} active={th.id === thread.threadId}
current={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))} onClick={() => pick(() => thread.openThread(th.id))}
trailing={ trailing={
<span className="flex-shrink-0 text-caption text-content-faint"> <span className="flex-shrink-0 text-caption text-content-faint">
@@ -246,7 +202,7 @@ export default function AssistantHeader({
)} )}
</PopoverListPanel> </PopoverListPanel>
<ContextMenu menu={menu.menu} onClose={closeMenu} /> <ContextMenu menu={menu.menu} onClose={menu.close} />
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />} {memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<ConfirmDialog <ConfirmDialog
isOpen={confirming} isOpen={confirming}
@@ -262,49 +218,3 @@ export default function AssistantHeader({
</header> </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 * 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 * keeps going from page to page. Inside a trip it is about that trip; what a
* one it is the home chat about all of them (AssistantWindow); what a
* card or an applied change opens goes through the planner's links: "Show on * 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 * 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 * 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()); act(() => useAssistantStore.getState().open());
expect(button).not.toHaveAttribute('aria-busy'); 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', () => { 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 * 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 * 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. * 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 * 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. * 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 available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open && !s.window.minimized); const open = useAssistantStore((s) => s.window.open && !s.window.minimized);
const toggle = useAssistantStore((s) => s.toggle); const toggle = useAssistantStore((s) => s.toggle);
// An answer still being written in a closed window: the floating button breathes until it is opened. A // An answer still being written in a closed window: the floating button breathes until it is opened. Only a
// trip's chat and the home chat count: an answer still running in the Studio is not one this window opens on. // trip's chat counts: an answer still running in the Studio is not one this window opens on.
const busy = useAssistantStore((s) => const busy = useAssistantStore((s) =>
!s.window.open || s.window.minimized !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 : false
); );
const keys = useAssistantShortcutKeys(); const keys = useAssistantShortcutKeys();
@@ -1,5 +1,5 @@
import type { AssistantFoundPlace } from '@trek/shared'; import type { AssistantFoundPlace } from '@trek/shared';
import { Check, MapPin, Plus, Route, Star } from 'lucide-react'; import { Check, MapPin, Plus, Star } from 'lucide-react';
import { createElement, type ReactNode, useRef } from 'react'; import { createElement, type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
@@ -25,24 +25,6 @@ function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip:
); );
} }
/** "+ 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>
);
}
type Category = ReturnType<Strip['categoryOf']>; type Category = ReturnType<Strip['categoryOf']>;
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */ /** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
@@ -203,9 +185,8 @@ export default function AssistantPlaceCard({
)} )}
</span> </span>
</button> </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} /> <PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
<TourButton place={place} strip={strip} tall={tall} />
</div> </div>
</li> </li>
); );
@@ -21,7 +21,6 @@ import { fetchPhoto } from '../../services/photoService';
import { assistantChatKey } from '../../store/assistantChatKey'; import { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { setOpenTour } from '../../store/openTourStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { setForcedOffline } from '../../sync/networkMode'; import { setForcedOffline } from '../../sync/networkMode';
import AssistantPlaceStrip from './AssistantPlaceStrip'; import AssistantPlaceStrip from './AssistantPlaceStrip';
@@ -106,7 +105,6 @@ afterEach(() => {
useAuthStore.setState(AUTH, true); useAuthStore.setState(AUTH, true);
globalThis.IntersectionObserver = RealObserver; globalThis.IntersectionObserver = RealObserver;
setForcedOffline(false); setForcedOffline(false);
setOpenTour(null);
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -240,22 +238,3 @@ describe('AssistantPlaceStrip', () => {
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull(); 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();
});
});
@@ -29,7 +29,6 @@ export default function AssistantSurface({
onFocusDay, onFocusDay,
onRevealMap, onRevealMap,
bookVersion, bookVersion,
onOpenTripChat,
}: AssistantSurfaceProps) { }: AssistantSurfaceProps) {
const runtime = useAssistantRuntime(tripId); const runtime = useAssistantRuntime(tripId);
// The phone is always compact; a window is what its width says. // The phone is always compact; a window is what its width says.
@@ -42,9 +41,8 @@ export default function AssistantSurface({
onFocusDay, onFocusDay,
onRevealMap, onRevealMap,
bookVersion, bookVersion,
onOpenTripChat,
}), }),
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat] [tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion]
); );
return ( return (
<AssistantRuntimeProvider runtime={runtime}> <AssistantRuntimeProvider runtime={runtime}>
@@ -1,5 +1,4 @@
import { ChevronRight, Luggage } from 'lucide-react'; import { ChevronRight, Luggage } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState'; import EmptyState from '../shared/EmptyState';
@@ -7,19 +6,12 @@ import { Spinner } from '../shared/Spinner';
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker'; 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 * Outside a trip the assistant first asks which one: the chat, its history
* head: that chat, its history and its changes belong to the trip. The * and its changes all belong to one trip. The running and the next trip lead.
* 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.
*/ */
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) { export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const trips = useAssistantTripPicker(); const trips = useAssistantTripPicker();
const first = useRef<HTMLButtonElement>(null);
useEffect(() => {
first.current?.focus({ preventScroll: true });
}, [trips]);
if (trips === null) { if (trips === null) {
return ( return (
@@ -39,10 +31,9 @@ export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerT
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2"> <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"> <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}> <li key={trip.id}>
<button <button
ref={i === 0 ? first : undefined}
type="button" type="button"
onClick={() => onPick(trip)} 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" 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 { t } = useTranslation();
const { tripId, variant } = useAssistantView(); 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. // The Studio's chat is about a book, not a plan.
const scope = useAssistantScope().kind; const book = useAssistantScope().kind === 'journey';
const book = scope === 'journey';
const hints = useAssistantHints(tripId, { withStays: true }); const hints = useAssistantHints(tripId, { withStays: true });
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS; const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions; const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6"> <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 && ( {!canEdit && (
<p className="m-0 max-w-xs text-center text-caption text-content-muted"> <p className="m-0 max-w-xs text-center text-caption text-content-muted">
{book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')} {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({ export function AssistantSuggestions({
suggestions, suggestions,
layout, 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 * 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 * opens from, the trip picker outside a trip, closing that keeps the chat, and
* and back, closing that keeps the chat, and
* Escape, which stops an answer before it closes anything (and leaves the * 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 * 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 * offers the notes its turns read. The geometry is
@@ -37,14 +36,12 @@ vi.mock('./AssistantSurfaceLazy', () => ({
density, density,
onFocusDay, onFocusDay,
onOpen, onOpen,
onOpenTripChat,
}: { }: {
tripId: number; tripId: number;
tripTitle: string; tripTitle: string;
density?: string; density?: string;
onFocusDay?: (dayId: number) => void; onFocusDay?: (dayId: number) => void;
onOpen?: (target: EntityTarget) => void; onOpen?: (target: EntityTarget) => void;
onOpenTripChat?: (trip: { id: number; title: string }, question: string | null) => void;
}) => ( }) => (
<div> <div>
<p> <p>
@@ -69,11 +66,6 @@ vi.mock('./AssistantSurfaceLazy', () => ({
</button> </button>
</> </>
)} )}
{onOpenTripChat && (
<button type="button" onClick={() => onOpenTripChat({ id: 9, title: 'Lisbon' }, 'What is missing?')}>
go on in Lisbon
</button>
)}
<textarea aria-label="composer" /> <textarea aria-label="composer" />
</div> </div>
), ),
@@ -144,36 +136,12 @@ describe('AssistantWindow', () => {
expect(container).toBeEmptyDOMElement(); 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 }); seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />); render(<AssistantWindow trip={null} />);
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument(); expect(screen.getByRole('dialog', { name: 'Which trip?' })).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' }));
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ })); fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument(); 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', () => { 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); 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', () => { 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 }); seed({ open: true, minimized: false });
const onFocusDay = vi.fn(); const onFocusDay = vi.fn();
@@ -298,10 +238,6 @@ describe('AssistantWindow', () => {
<Location /> <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(await screen.findByRole('button', { name: /Lisbon/ }));
fireEvent.click(screen.getByRole('button', { name: 'open tab' })); fireEvent.click(screen.getByRole('button', { name: 'open tab' }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/9?tab=finanzplan&expense=3'); expect(screen.getByTestId('location')).toHaveTextContent('/trips/9?tab=finanzplan&expense=3');
@@ -1,21 +1,20 @@
import './assistant.css'; import './assistant.css';
import { Sparkles, X } from 'lucide-react';
import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import type { WindowRect } from '../../hooks/useDragResize'; import type { WindowRect } from '../../hooks/useDragResize';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { useAssistantStore } from '../../store/assistantStore'; import { useAssistantStore } from '../../store/assistantStore';
import AssistantFrameEdges from './AssistantFrameEdges'; import AssistantFrameEdges from './AssistantFrameEdges';
import AssistantHeader from './AssistantHeader'; import AssistantHeader from './AssistantHeader';
import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher'; import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher';
import AssistantSurfaceLazy from './AssistantSurfaceLazy'; import AssistantSurfaceLazy from './AssistantSurfaceLazy';
import AssistantTripPicker from './AssistantTripPicker';
import { FRAME_CLASS, useAssistantFrame } from './assistantFrame'; import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
import { plannerSearchOf } from './assistantShowOnMap'; import { plannerSearchOf } from './assistantShowOnMap';
import type { AssistantChatTrip } from './assistantViewContext';
import type { EntityTarget } from './cards/cardTypes'; import type { EntityTarget } from './cards/cardTypes';
import { useAssistantHome } from './useAssistantHome';
import { useAssistantHotkey } from './useAssistantHotkey'; import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantThread } from './useAssistantThread'; import { useAssistantThread } from './useAssistantThread';
@@ -31,18 +30,17 @@ import { useAssistantThread } from './useAssistantThread';
* an answer is still being written. Escape stops an answer first, then * an answer is still being written. Escape stops an answer first, then
* closes, and the focus goes back to where it was. * 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 * Mounted once for the whole app; it shows nothing until the assistant has a
* connection this user can use. * connection this user can use.
*/ */
type WindowTrip = AssistantChatTrip; interface WindowTrip {
id: number;
title: string;
}
interface AssistantWindowProps { 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; trip: WindowTrip | null;
/** Opens what a card or an applied change points at, from the trip on screen. */ /** Opens what a card or an applied change points at, from the trip on screen. */
onOpen?: (target: EntityTarget) => void; onOpen?: (target: EntityTarget) => void;
@@ -72,16 +70,12 @@ function ChatWindow({
onOpen, onOpen,
onFocusDay, onFocusDay,
onRevealMap, onRevealMap,
onOpenTripChat,
tripSwitch,
}: { }: {
trip: WindowTrip; trip: WindowTrip;
hidden: boolean; hidden: boolean;
onOpen?: (target: EntityTarget) => void; onOpen?: (target: EntityTarget) => void;
onFocusDay?: (dayId: number) => void; onFocusDay?: (dayId: number) => void;
onRevealMap?: () => void; onRevealMap?: () => void;
onOpenTripChat?: (trip: WindowTrip, question: string | null) => void;
tripSwitch?: Parameters<typeof AssistantHeader>[0]['tripSwitch'];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const thread = useAssistantThread(trip.id); const thread = useAssistantThread(trip.id);
@@ -156,7 +150,6 @@ function ChatWindow({
thread={thread} thread={thread}
tripTitle={trip.title} tripTitle={trip.title}
dragProps={frame.handleProps} dragProps={frame.handleProps}
tripSwitch={tripSwitch}
// Closing only hides the window: an answer being written keeps coming in, // Closing only hides the window: an answer being written keeps coming in,
// and the floating button says so until it is opened again. // and the floating button says so until it is opened again.
onClose={() => setMinimized(true)} onClose={() => setMinimized(true)}
@@ -169,7 +162,6 @@ function ChatWindow({
onOpen={onOpen} onOpen={onOpen}
onFocusDay={onFocusDay} onFocusDay={onFocusDay}
onRevealMap={onRevealMap} onRevealMap={onRevealMap}
onOpenTripChat={onOpenTripChat}
/> />
</div> </div>
</section> </section>
@@ -185,21 +177,19 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
const toggle = useAssistantStore((s) => s.toggle); const toggle = useAssistantStore((s) => s.toggle);
const [picked, setPicked] = useState<WindowTrip | null>(null); const [picked, setPicked] = useState<WindowTrip | null>(null);
const [wasOpen, setWasOpen] = useState(open); const [wasOpen, setWasOpen] = useState(open);
const home = useAssistantHome(setPicked);
useEffect(() => { useEffect(() => {
void loadStatus(); void loadStatus();
}, [loadStatus]); }, [loadStatus]);
useAssistantHotkey(available, toggle); 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) { if (open !== wasOpen) {
setWasOpen(open); setWasOpen(open);
if (!open) setPicked(null); if (!open) setPicked(null);
} }
const current = trip ?? picked ?? home.chat; const current = trip ?? picked;
const atHome = current.id === HOME_CHAT_KEY;
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it. // A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
const openPicked = useCallback( const openPicked = useCallback(
(target: EntityTarget) => { (target: EntityTarget) => {
@@ -208,14 +198,6 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
}, },
[navigate, picked] [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. // Another page leaves the trip: the chat stays on it, as if it had been picked.
const openOnScreen = useCallback( const openOnScreen = useCallback(
(target: EntityTarget) => { (target: EntityTarget) => {
@@ -227,27 +209,52 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
if (!available) return null; if (!available) return null;
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : 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; 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 ( return (
<> <>
<ChatWindow <ChatWindow
trip={current} trip={current}
hidden={minimized} hidden={minimized}
onOpen={openTarget} onOpen={onScreen ? onOpen && openOnScreen : openPicked}
// Only the planner on screen has days to select and a map to bring back. // Only the planner on screen has days to select and a map to bring back.
onFocusDay={onScreen ? onFocusDay : undefined} onFocusDay={onScreen ? onFocusDay : undefined}
onRevealMap={onScreen ? onRevealMap : 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 && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
{minimized && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} 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>
);
}
@@ -107,7 +107,6 @@ const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
decide_first: { messageKey: 'assistant.error.decideFirst', action: null }, decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null }, too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', 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 }, import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
generic: { generic: {
messageKey: 'assistant.error.generic', messageKey: 'assistant.error.generic',
@@ -225,17 +224,6 @@ const PHASE_SUGGESTIONS: Record<TripPhase, AssistantSuggestion[]> = {
const MAX_SUGGESTIONS = 4; 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, * 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 * then what fits the trip's phase. The label is also the prompt, so a tapped
@@ -127,10 +127,6 @@ function namedFirst(places: readonly AssistantFoundPlace[], text: string): Assis
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([ const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'propose_hike', 'propose_hike',
'create_hike', 'create_hike',
'propose_tour',
'create_tour',
'create_paddle_tour',
'create_ski_tour',
'update_tour', 'update_tour',
'propose_city_walk', 'propose_city_walk',
'create_city_walk', 'create_city_walk',
@@ -138,9 +134,6 @@ const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'plan_ski_route', 'plan_ski_route',
'prepare_tour_change', 'prepare_tour_change',
'save_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. */ /** The tool that keeps a note in the trip notes; kept at once, without a card. */
@@ -462,5 +455,4 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
difficulty: { labelKey: 'assistant.change.label.difficulty' }, difficulty: { labelKey: 'assistant.change.label.difficulty' },
track: { labelKey: 'assistant.change.label.track' }, track: { labelKey: 'assistant.change.label.track' },
packing: { labelKey: 'assistant.change.label.packing' }, packing: { labelKey: 'assistant.change.label.packing' },
breaks: { labelKey: 'assistant.change.label.breaks' },
}; };
@@ -34,17 +34,6 @@ export interface AssistantView {
* version the trial ran on. * version the trial ran on.
*/ */
bookVersion?: number | null; 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); 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 * 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 * 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 * the card carries it and the book is still at the trial's version), and
* where an applied change opens the Studio. * 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 { describe, expect, it } from 'vitest';
import { 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', () => { 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: '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: '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: '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: 'Mine' }, t)).toBe('Mine');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1'); 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'); 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', () => { 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 doc = book();
const [edit] = pageViewOf(card(), doc, 'pending'); const [edit] = pageViewOf(card(), doc, 'pending');
@@ -10,7 +10,6 @@ import {
type BookSpread, type BookSpread,
findBookGrid, findBookGrid,
findBookTemplate, findBookTemplate,
findDecoratedTemplate,
setBookCover, setBookCover,
} from '@trek/shared'; } from '@trek/shared';
@@ -58,15 +57,6 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
font_mix: { labelKey: 'assistant.card.book.finding.font_mix' }, font_mix: { labelKey: 'assistant.card.book.finding.font_mix' },
palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' }, palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' },
repeat_layout: { labelKey: 'assistant.card.book.finding.repeat_layout' }, 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. */ /** 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); const grid = findBookGrid(layout.id);
return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null; 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; 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. // 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' }, preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' }, list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
list_book_assets: { stepKey: 'assistant.step.bookAssets' },
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' }, suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
}, },
entities: { 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'; import type { PackLook } from '../cardTypes';
@@ -19,7 +19,6 @@ export const BOOK_DESIGN_LOOK = {
verbs: { verbs: {
designBook: { Icon: Sparkles, tone: 'info', labelKey: 'assistant.change.verb.designBook' }, designBook: { Icon: Sparkles, tone: 'info', labelKey: 'assistant.change.verb.designBook' },
setBookSetup: { Icon: Ruler, tone: 'info', labelKey: 'assistant.change.verb.setBookSetup' }, 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' }, duplicateBookPage: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.duplicateBookPage' },
saveBookLayout: { Icon: BookmarkPlus, tone: 'success', labelKey: 'assistant.change.verb.saveBookLayout' }, saveBookLayout: { Icon: BookmarkPlus, tone: 'success', labelKey: 'assistant.change.verb.saveBookLayout' },
removeBookLayout: { Icon: BookmarkMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeBookLayout' }, 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' }, journal: { labelKey: 'assistant.card.bookDesign.style.journal' },
bold: { labelKey: 'assistant.card.bookDesign.style.bold' }, bold: { labelKey: 'assistant.card.bookDesign.style.bold' },
scrapbook: { labelKey: 'assistant.card.bookDesign.style.scrapbook' }, 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; 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 type { CardLook, EntityLook, StepLook, VerbLook } from './cardTypes';
import { COLLAB_LOOK } from './collab/collabLook'; import { COLLAB_LOOK } from './collab/collabLook';
import { COLLECTION_LOOK } from './collection/collectionLook'; import { COLLECTION_LOOK } from './collection/collectionLook';
import { HOME_LOOK } from './home/homeLook';
import { JOURNEY_LOOK } from './journey/journeyLook'; import { JOURNEY_LOOK } from './journey/journeyLook';
import { PACKING_LOOK } from './packing/packingLook'; import { PACKING_LOOK } from './packing/packingLook';
import { ROADTRIP_LOOK } from './roadtrip/roadtripLook'; import { ROADTRIP_LOOK } from './roadtrip/roadtripLook';
@@ -171,7 +170,6 @@ export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
...PACKING_LOOK.steps, ...PACKING_LOOK.steps,
...BOOK_LOOK.steps, ...BOOK_LOOK.steps,
...BOOK_DESIGN_LOOK.steps, ...BOOK_DESIGN_LOOK.steps,
...HOME_LOOK.steps,
}; };
/** Shown on the map by its day: a stop or a note lives on one. */ /** Shown on the map by its day: a stop or a note lives on one. */
@@ -236,7 +234,6 @@ export const CARD_LOOKS: { readonly [K in TrekCardKind]: CardLook<K> } = {
...PACKING_LOOK.cards, ...PACKING_LOOK.cards,
...BOOK_LOOK.cards, ...BOOK_LOOK.cards,
...BOOK_DESIGN_LOOK.cards, ...BOOK_DESIGN_LOOK.cards,
...HOME_LOOK.cards,
}; };
/** The look of the card in hand, typed for any kind. */ /** 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 type { CardView } from './cardTypes';
import { COLLAB_VIEWS } from './collab/collabViews'; import { COLLAB_VIEWS } from './collab/collabViews';
import { COLLECTION_VIEWS } from './collection/collectionViews'; import { COLLECTION_VIEWS } from './collection/collectionViews';
import { HOME_VIEWS } from './home/homeViews';
import { JOURNEY_VIEWS } from './journey/journeyViews'; import { JOURNEY_VIEWS } from './journey/journeyViews';
import { PACKING_VIEWS } from './packing/packingViews'; import { PACKING_VIEWS } from './packing/packingViews';
import { ROADTRIP_VIEWS } from './roadtrip/roadtripViews'; import { ROADTRIP_VIEWS } from './roadtrip/roadtripViews';
@@ -34,7 +33,6 @@ export const CARD_VIEWS: { readonly [K in TrekCardKind]: CardView<K> } = {
...PACKING_VIEWS, ...PACKING_VIEWS,
...BOOK_VIEWS, ...BOOK_VIEWS,
...BOOK_DESIGN_VIEWS, ...BOOK_DESIGN_VIEWS,
...HOME_VIEWS,
}; };
/** The view of the card in hand, typed for any kind. */ /** 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. * 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. */ /** A group's head: its colour, its name, a badge, and who looks after it (the ones a change drops faded). */
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.
*/
export function ChecklistHead({ export function ChecklistHead({
color, color,
name, name,
@@ -47,17 +38,14 @@ export function ChecklistHead({
lead?: ReactNode; lead?: ReactNode;
}) { }) {
return ( return (
<div <div className="flex min-w-0 items-center gap-2 pt-1">
className="mt-1.5 flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5"
style={{ background: bandOf(color) }}
>
{lead ?? <CategoryDot color={color} />} {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} {name}
</bdi> </bdi>
{(badge || people.length > 0 || removed.length > 0) && ( {badge}
<span className="flex flex-none items-center gap-1.5"> {(people.length > 0 || removed.length > 0) && (
{badge} <span className="ms-auto flex flex-none items-center gap-1.5">
<PeopleStack people={people} /> <PeopleStack people={people} />
{/* The people a change takes off, faded; the card says "No longer" in words under the head. */} {/* The people a change takes off, faded; the card says "No longer" in words under the head. */}
{removed.length > 0 && ( {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. */ /** A row's quantity and weight: quiet figures at its end, no frame each. */
const QUIET = 'text-caption tabular-nums text-content-muted'; 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 }) { function GroupCount({ count }: { count: number }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<span <span
aria-label={t('tours.packing.items', { count })} 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} {count}
</span> </span>
@@ -231,7 +231,7 @@ function BagHead({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (!bag) { if (!bag) {
const badge = <GroupCount count={group.count} />; const badge = inChange ? undefined : <GroupCount count={group.count} />;
const lead = <BagSwatch bag={undefined} size="head" />; const lead = <BagSwatch bag={undefined} size="head" />;
return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />; return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />;
} }
@@ -298,7 +298,7 @@ export function PackingGroup({
<ChecklistHead <ChecklistHead
color={packingColorOf(key.slot, name)} color={packingColorOf(key.slot, name)}
name={name} name={name}
badge={<GroupCount count={group.count} />} badge={inChange ? undefined : <GroupCount count={group.count} />}
people={group.assignees} people={group.assignees}
removed={group.assigneesRemoved} removed={group.assigneesRemoved}
/> />
@@ -5,7 +5,6 @@ import { useTranslation } from '../../../../i18n';
import { useSettingsStore } from '../../../../store/settingsStore'; import { useSettingsStore } from '../../../../store/settingsStore';
import { formatClockTime, formatDate, formatMoney } from '../../../../utils/formatters'; import { formatClockTime, formatDate, formatMoney } from '../../../../utils/formatters';
import { formatDistance, formatElevation } from '../../../../utils/units'; 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 * 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. * units. A card hands these to its words as `{value}`, never a raw number.
*/ */
export function useCardFormat() { export function useCardFormat() {
const { locale, t } = useTranslation(); const { locale } = useTranslation();
const twelveHour = useSettingsStore((s) => s.settings.time_format === '12h'); const twelveHour = useSettingsStore((s) => s.settings.time_format === '12h');
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric'); const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
return useMemo(() => { return useMemo(() => {
@@ -38,8 +37,6 @@ export function useCardFormat() {
return m === 0 ? hours.format(h) : `${hours.format(h)} ${mins.format(m)}`; return m === 0 ? hours.format(h) : `${hours.format(h)} ${mins.format(m)}`;
}, },
count: (value: number) => number.format(value), 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 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 * 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(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
expect(screen.getAllByText('Start by 10:30')).toHaveLength(1); 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 { useCardFormat } from '../parts/useCardFormat';
import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel'; import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel'; 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 * 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 * 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 * 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 * means, the routing limit and where grades come from in its tooltip. Any
* tour or a run names its profile instead, any other tour what kind it is. * other tour says what kind it is instead.
*/ */
export function TourGrade({ card }: { card: TourRouteCard }) { export function TourGrade({ card }: { card: TourRouteCard }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (card.activity !== 'hike' || !card.grades) { if (card.activity !== 'hike' || !card.grades) {
const icon = createElement(tourDayRowIcon(card.activity), { size: 10, strokeWidth: 2.2, 'aria-hidden': true }); 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". return <SoftPill icon={icon}>{t(TOUR_TYPE_WORDS[card.activity].labelKey)}</SoftPill>;
const words = card.routingProfile ? PROFILE_WORDS[card.routingProfile] : TOUR_TYPE_WORDS[card.activity];
return <SoftPill icon={icon}>{t(words.labelKey)}</SoftPill>;
} }
const { highest, unknownShare } = card.grades; const { highest, unknownShare } = card.grades;
const uncertain = unknownShare >= 0.5; const uncertain = unknownShare >= 0.5;
@@ -140,7 +138,7 @@ export function TourRouteSketch({
export function TourRouteFields({ card }: { card: TourRouteCard }) { export function TourRouteFields({ card }: { card: TourRouteCard }) {
const { t } = useTranslation(); const { t } = useTranslation();
const format = useCardFormat(); 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); const value = factValue(card, key);
if (value === null) return []; if (value === null) return [];
// A field's label is short; how the time was reckoned is the figure badges' tooltip. // 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); else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
return [{ key, label, value: text }]; 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} />; return <CardFields fields={fields} />;
} }
@@ -179,9 +173,9 @@ export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; o
title={name} title={name}
meta={card.label ?? undefined} 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"> <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} /> <DayPill card={card} />
</div> </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 * 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 * 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('+1.6 km');
expect(delta).toHaveTextContent('+20 min'); 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"> <span className="min-w-0 flex-1">
<WayFigures way={legVariantOf(card, 'current')} unit={unit} /> <WayFigures way={legVariantOf(card, 'current')} unit={unit} />
</span> </span>
{((grades && grades.highest !== null) || card.routingProfile) && <TourGrade card={card} />} {grades && grades.highest !== null && <TourGrade card={card} />}
</span> </span>
{previous && ( {previous && (
<VariantDelta <VariantDelta
@@ -18,7 +18,6 @@ import CardHeader from '../parts/CardHeader';
import { CardSection } from '../parts/CardSection'; import { CardSection } from '../parts/CardSection';
import WayFigures from './TourFigureLine'; import WayFigures from './TourFigureLine';
import { TourRouteBody } from './TourRouteCard'; import { TourRouteBody } from './TourRouteCard';
import { TourGrade } from './TourRouteHead';
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel'; import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords'; import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords';
import { useLineHover, useTourRouteCard } from './useTourRouteCard'; import { useLineHover, useTourRouteCard } from './useTourRouteCard';
@@ -115,8 +114,6 @@ function WayOption({
{grades && grades.highest !== null && ( {grades && grades.highest !== null && (
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} /> <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> </span>
<WayFigures way={way} unit={unit} /> <WayFigures way={way} unit={unit} />
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} 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 { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
import { import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
ArrowLeftRight,
ChartNoAxesColumn,
Circle,
CircleCheck,
CircleSlash,
Copy,
Footprints,
History,
Luggage,
MapPinMinus,
MapPinPlus,
Palette,
Repeat,
Route,
Snowflake,
TriangleAlert,
Waves,
} from 'lucide-react';
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap'; import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
import type { EntityTarget, PackLook } from '../cardTypes'; import type { EntityTarget, PackLook } from '../cardTypes';
@@ -113,21 +95,10 @@ export const TOUR_LOOK = {
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' }, countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' }, linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' }, 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: {}, tools: {},
steps: { steps: {
propose_city_walk: { stepKey: 'assistant.step.planningWalk' }, propose_city_walk: { stepKey: 'assistant.step.planningWalk' },
propose_tour: { stepKey: 'assistant.step.planningRoute' },
plan_paddle_route: { stepKey: 'assistant.step.planningRoute' }, plan_paddle_route: { stepKey: 'assistant.step.planningRoute' },
plan_ski_route: { stepKey: 'assistant.step.planningRoute' }, plan_ski_route: { stepKey: 'assistant.step.planningRoute' },
get_tour_plan: { stepKey: 'assistant.step.readingTour' }, get_tour_plan: { stepKey: 'assistant.step.readingTour' },
@@ -135,14 +106,6 @@ export const TOUR_LOOK = {
find_tours_for_day: { stepKey: 'assistant.step.readingTours' }, find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
search_tour_pois: { stepKey: 'assistant.step.tourPois' }, search_tour_pois: { stepKey: 'assistant.step.tourPois' },
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' }, 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: { entities: {
tour: { tour: {
@@ -29,7 +29,6 @@ import {
sacAt, sacAt,
sacPaint, sacPaint,
steepStretches, steepStretches,
takeWaitsKey,
wayGroups, wayGroups,
} from './tourRouteModel'; } from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel'; 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)).toBe(true);
expect(canTake({ ...HIKE, routed: false })).toBe(false); expect(canTake({ ...HIKE, routed: false })).toBe(false);
expect(canTake({ ...HIKE, activity: 'walk', routed: false })).toBe(true); 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(SAVED)).toBe(false);
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(true); expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(false);
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, source: 'pending' })).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', () => { 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 * Whether "Plan this" can take the proposal: a hike whose way was planned,
* tour or a run whose way was planned, or a paddle or ski plan whose every * or a city walk. A paddle or ski plan, a saved tour and a held change cannot.
* 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.
*/ */
export function canTake(card: TourRouteCard): boolean { export function canTake(card: TourRouteCard): boolean {
if (card.source === 'plan') { if (card.source !== 'proposal') return false;
const whole = card.warnings.every((warning) => warning.kind !== 'legs_failed'); return card.activity === 'walk' || (card.activity === 'hike' && card.routed);
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);
} }
/** 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 %". */ /** A share as words take it, "40 %". */
export function percentText(share: number): string { export function percentText(share: number): string {
return `${Math.round(share * 100)} %`; return `${Math.round(share * 100)} %`;
@@ -92,25 +92,10 @@ const TIME_WORDS = {
planned: { labelKey: 'tours.scope.planned' }, planned: { labelKey: 'tours.scope.planned' },
stations: { labelKey: 'tours.scope.plannedWithStays' }, stations: { labelKey: 'tours.scope.plannedWithStays' },
piste: { labelKey: 'tours.ski.time' }, piste: { labelKey: 'tours.ski.time' },
pace: { labelKey: 'tours.run.timeEstimated' },
} as const satisfies Record<NonNullable<TourRouteCard['minutesBasis']>, { labelKey: string }>; } as const satisfies Record<NonNullable<TourRouteCard['minutesBasis']>, { labelKey: string }>;
/** What each bike and run profile is called, as the Tours tab names it. */ /** The name of a route's time: by how it was reckoned, else a paddle's own, else a walking time. */
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.
*/
export function timeLabelKey(card: Pick<TourRouteCard, 'minutesBasis' | 'activity'>): string { 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; if (card.minutesBasis) return TIME_WORDS[card.minutesBasis].labelKey;
return card.activity === 'kayak' ? 'tours.paddle.timeEstimated' : 'tours.planner.walkingEstimated'; return card.activity === 'kayak' ? 'tours.paddle.timeEstimated' : 'tours.planner.walkingEstimated';
} }
@@ -11,7 +11,7 @@ import { hasUndecided } from '../../assistantParts';
import { useAssistantView } from '../../assistantViewContext'; import { useAssistantView } from '../../assistantViewContext';
import { useChangeHover } from '../../useChangeHover'; import { useChangeHover } from '../../useChangeHover';
import { tourTarget } from './tourLook'; import { tourTarget } from './tourLook';
import { canTake, routePoints, takeWaitsKey } from './tourRouteModel'; import { canTake, routePoints } from './tourRouteModel';
import { takeInThread, takeOfProposal } from './tourTakeModel'; 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) * "Plan this" on a proposed hike or city walk: one tap adds the tour. The
* or a paddle or ski plan: one tap adds the tour. The
* server holds the tour it creates as a change, without a model turn, and * 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 * 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 * 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); const visible = canTake(card) && !!trip && can('place_edit', trip);
let reasonKey: string | null; let reasonKey: string | null = null;
if (offline) reasonKey = 'assistant.error.offline'; if (offline) reasonKey = 'assistant.error.offline';
else if (waiting && state !== 'taken') reasonKey = 'assistant.composer.decideFirst'; else if (waiting && state !== 'taken') reasonKey = 'assistant.composer.decideFirst';
else reasonKey = takeWaitsKey(card);
const disabled = reasonKey !== null || isRunning || threadId === null || state === 'busy'; const disabled = reasonKey !== null || isRunning || threadId === null || state === 'busy';
const take = useCallback(async () => { 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 { type DragEvent, useState } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
@@ -6,7 +6,7 @@ import { assistantScopeOf } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useToast } from '../shared/Toast'; 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[] = [ const ATTACH_EXTENSIONS: readonly string[] = [
'.pdf', '.pdf',
'.eml', '.eml',
@@ -16,7 +16,6 @@ const ATTACH_EXTENSIONS: readonly string[] = [
'.htm', '.htm',
'.txt', '.txt',
...LLM_PHOTO_EXTENSIONS, ...LLM_PHOTO_EXTENSIONS,
...ASSISTANT_TOUR_FILE_EXTENSIONS,
]; ];
const MAX_ATTACH_BYTES = 10 * 1024 * 1024; 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 * 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. * 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. * 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 };
}
@@ -3,7 +3,6 @@ import { useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useOpenTourStore } from '../../store/openTourStore';
import { useCanDo } from '../../store/permissionsStore'; import { useCanDo } from '../../store/permissionsStore';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
@@ -42,10 +41,6 @@ function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng:
* Adding opens the planner's own place form, filled in with the place and on * 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 * 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. * 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.
*/ */
export function useAssistantPlaceStrip() { export function useAssistantPlaceStrip() {
const { tripId, onRevealMap } = useAssistantView(); const { tripId, onRevealMap } = useAssistantView();
@@ -65,8 +60,6 @@ export function useAssistantPlaceStrip() {
); );
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null); const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft); const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft);
const insertFoundPlace = useAssistantStore((s) => s.insertFoundPlace);
const openTour = useOpenTourStore((s) => (s.open?.tripId === tripId ? s.open : null));
const point = useCardPoint(); const point = useCardPoint();
const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null; const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null;
@@ -92,14 +85,6 @@ export function useAssistantPlaceStrip() {
openPlaceDraft(trip.id, place, focusDay?.id ?? null); 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);
};
/** Shows the place on the map; on the phone the chat steps aside first. */ /** Shows the place on the map; on the phone the chat steps aside first. */
const show = point.show; const show = point.show;
@@ -111,5 +96,5 @@ export function useAssistantPlaceStrip() {
[hoverPoint] [hoverPoint]
); );
return { canAdd, categoryOf, distanceOf, open, show, hover, tour, addToTour }; return { canAdd, categoryOf, distanceOf, open, show, hover };
} }
@@ -6,7 +6,7 @@ import { useJourneyStore } from '../../store/journeyStore';
import { useCanDo } from '../../store/permissionsStore'; import { useCanDo } from '../../store/permissionsStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { localIsoDate } from '../../utils/localDate'; import { localIsoDate } from '../../utils/localDate';
import { HOME_SUGGESTIONS, suggestionsFor, tripPhase } from './assistantModel'; import { suggestionsFor, tripPhase } from './assistantModel';
import { useAssistantFocus } from './useAssistantFocus'; import { useAssistantFocus } from './useAssistantFocus';
export interface ReadySuggestion { export interface ReadySuggestion {
@@ -21,9 +21,7 @@ export interface ReadySuggestion {
* (one picked from the dashboard) neither its dates nor its rights are at * (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 * 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, * 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 * and its rights are the journey's role, as the Studio itself reads it.
* 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.
*/ */
export function useAssistantSuggestions(tripId: number): { suggestions: ReadySuggestion[]; canEdit: boolean } { export function useAssistantSuggestions(tripId: number): { suggestions: ReadySuggestion[]; canEdit: boolean } {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -37,11 +35,9 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug
const journeyRole = useJourneyStore((s) => const journeyRole = useJourneyStore((s) =>
scope.kind === 'journey' && s.current?.id === scope.journeyId ? s.current.my_role : undefined 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) : (journeyRole ?? 'owner') !== 'viewer';
const canEdit = tripChat ? !!trip && can('place_edit', trip) : home || (journeyRole ?? 'owner') !== 'viewer';
const suggestions = useMemo((): ReadySuggestion[] => { const suggestions = useMemo((): ReadySuggestion[] => {
if (home) return HOME_SUGGESTIONS.map((s) => ({ id: s.id, prompt: t(s.labelKey) }));
if (!tripChat) return []; if (!tripChat) return [];
const phase = trip const phase = trip
? tripPhase({ ? tripPhase({
@@ -57,7 +53,7 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug
place: kind === 'place' ? label : null, place: kind === 'place' ? label : null,
day: kind === 'day' ? label : null, day: kind === 'day' ? label : null,
}).map((s) => ({ id: s.id, prompt: t(s.labelKey, s.params) })); }).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 }; 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. // Plyr needs a real media pipeline, so the player is stubbed here.
vi.mock('./VideoPlayer', () => ({ vi.mock('./VideoPlayer', () => ({
@@ -17,7 +17,6 @@ vi.mock('../../api/websocket', () => ({
import { render, screen, fireEvent } from '../../../tests/helpers/render'; import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store'; import { resetAllStores } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import PhotoLightbox from './PhotoLightbox'; import PhotoLightbox from './PhotoLightbox';
const samplePhotos = [ const samplePhotos = [
@@ -248,82 +247,4 @@ describe('PhotoLightbox', () => {
expect(img.style.transform).toContain('scale(2)'); expect(img.style.transform).toContain('scale(2)');
expect(onClose).not.toHaveBeenCalled(); 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 VideoPlayer from './VideoPlayerLazy'
import { usePhotoZoom } from './usePhotoZoom' import { usePhotoZoom } from './usePhotoZoom'
import { Tooltip } from '../shared/Tooltip' import { Tooltip } from '../shared/Tooltip'
import { isRtlLanguage, useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
interface LightboxPhoto { interface LightboxPhoto {
id: string id: string
src: string src: string
caption?: string | null 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 provider?: string
asset_id?: string | null asset_id?: string | null
owner_id?: number | null owner_id?: number | null
@@ -21,8 +19,6 @@ interface Props {
photos: LightboxPhoto[] photos: LightboxPhoto[]
startIndex?: number startIndex?: number
onClose: () => void onClose: () => void
/** What the dialog is called for assistive tech. */
label?: string
} }
/** The round glass buttons of the top bar. */ /** The round glass buttons of the top bar. */
@@ -32,21 +28,10 @@ const ROUND_BTN: React.CSSProperties = {
color: '#fff', cursor: 'pointer', 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 [idx, setIdx] = useState(startIndex)
const touchStart = useRef<{ x: number; y: number } | null>(null) const touchStart = useRef<{ x: number; y: number } | null>(null)
const dialogRef = useRef<HTMLDivElement>(null) const { t } = useTranslation()
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 photo = photos[idx] const photo = photos[idx]
// Zoom starts over on every photo (#1484). // Zoom starts over on every photo (#1484).
@@ -61,12 +46,12 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') (rtl ? next : prev)() if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') (rtl ? prev : next)() if (e.key === 'ArrowRight') next()
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('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 // 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. // 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 // horizontal swipe
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) { if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
if (dx < 0 !== rtl) next() if (dx < 0) next()
else prev() else prev()
} }
touchStart.current = null touchStart.current = null
@@ -104,13 +89,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
return ( return (
<div <div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
style={{ style={{
outline: 'none',
position: 'fixed', inset: 0, zIndex: 10000, position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)', background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column', 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) */} {/* Prev button — visible on hover (desktop), always visible (mobile) */}
{hasPrev && ( {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={{ <button type="button" onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', insetInlineStart: 16, zIndex: 5, position: 'absolute', left: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%', width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer', color: '#fff', cursor: 'pointer',
}}> }}>
<ChevronLeft size={22} className="rtl:rotate-180" /> <ChevronLeft size={22} />
</button> </button>
)} )}
@@ -200,33 +179,27 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
{/* Next button */} {/* Next button */}
{hasNext && ( {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={{ <button type="button" onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', insetInlineEnd: 16, zIndex: 5, position: 'absolute', right: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%', width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer', color: '#fff', cursor: 'pointer',
}}> }}>
<ChevronRight size={22} className="rtl:rotate-180" /> <ChevronRight size={22} />
</button> </button>
)} )}
{/* Caption — bottom center overlay */} {/* Caption — bottom center overlay */}
{photo.caption && ( {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={{ <p style={{
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontStyle: 'italic', 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)', background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
padding: '6px 14px', borderRadius: 10, pointerEvents: 'auto', padding: '6px 14px', borderRadius: 10,
}}> }}>{photo.caption}</p>
{photo.captionHref ? (
<a href={photo.captionHref} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }} className="hover:underline">
{photo.caption}
</a>
) : photo.caption}
</p>
</div> </div>
)} )}
</div> </div>
@@ -15,6 +15,8 @@ import {
calculateAlternatives, calculateAlternatives,
avoidedClasses, avoidedClasses,
routeEngineFor, routeEngineFor,
sameRoad,
furthestFrom,
osrmLegSegment, osrmLegSegment,
} from './RouteCalculator' } from './RouteCalculator'
@@ -1202,6 +1204,37 @@ describe('a trip that avoids something', () => {
expect(plain.avoidance).toBeUndefined() 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 () => { it('FE-COMP-ROUTECALCULATOR-066: the avoidance offer beside an OSRM leg says when it crosses by ferry', async () => {
server.use( server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 63], [11, 63], [12, 63]] }, distance: 100000, duration: 3600 }] })), 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 { formatDistance } from '../../utils/units'
import { countRoute } from './routeUsageCounter' import { countRoute } from './routeUsageCounter'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute' 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 // 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 // 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 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. * 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) const furthest = furthestFrom(line, reference)
return furthest ? { lat: furthest.lat, lng: furthest.lng } : null 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 { describe, it, expect, vi } from 'vitest'
import { useRef } from 'react' import { useRef } from 'react'
import { render, screen, fireEvent } from '../../../tests/helpers/render' import { render, screen, fireEvent } from '../../../tests/helpers/render'
@@ -56,15 +56,6 @@ describe('PopoverItem', () => {
expect(danger.style.background).toBe('none') 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', () => { 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(false)).toMatchObject({ opacity: 0.35, cursor: 'default' })
expect(composerConfirm(true)).toMatchObject({ opacity: 1, cursor: 'pointer' }) expect(composerConfirm(true)).toMatchObject({ opacity: 1, cursor: 'pointer' })
@@ -7,8 +7,6 @@ interface PopoverItemProps {
label: string label: string
onClick: () => void onClick: () => void
active?: boolean 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 danger?: boolean
/** A secondary line, such as Add bag, in the faint text colour. */ /** A secondary line, such as Add bag, in the faint text colour. */
muted?: boolean muted?: boolean
@@ -16,12 +14,11 @@ interface PopoverItemProps {
trailing?: ReactNode 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)' const tone = danger ? '#ef4444' : muted ? 'var(--text-faint)' : 'var(--text-primary)'
return ( return (
<button type="button" <button type="button"
onClick={onClick} onClick={onClick}
aria-current={current || undefined}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 9, width: '100%', display: 'flex', alignItems: 'center', gap: 9, width: '100%',
padding: '8px 10px', borderRadius: 9, border: 'none', cursor: 'pointer', 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 () => { it('FE-PDC-004: renders author and licence under every Commons picture', async () => {
renderColumn({ selectedImageUrl: COMMONS_PHOTO.url }) renderColumn()
const credit = await screen.findByText('Alice') const credit = await screen.findByText('Alice')
// Links to the file description page, where the full terms are. // 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 () => { 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: [] }) placeEnrichment.mockResolvedValue({ photos: [GOOGLE_PHOTO], description: null, facts: [] })
renderColumn({ selectedImageUrl: GOOGLE_PHOTO.url }) renderColumn()
expect(await screen.findByText('Google')).toBeInTheDocument() 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 () => { it('FE-PDC-006: picking a picture reports its proxy URL', async () => {
@@ -613,24 +611,17 @@ describe('PlaceDetailsColumn — picture layout', () => {
withPhotos(3) withPhotos(3)
const { onPickImage } = renderColumn() 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') await screen.findByText('Author 0')
expect(screen.queryByText('places.image.none')).not.toBeInTheDocument()
const tiles = screen.getAllByRole('button', { name: /places.details.pickImage/ }) const tiles = screen.getAllByRole('button', { name: /places.details.pickImage/ })
fireEvent.mouseEnter(tiles[2]) fireEvent.mouseEnter(tiles[2])
expect(screen.getByText('Author 2')).toBeInTheDocument() expect(screen.getByText('Author 2')).toBeInTheDocument()
expect(screen.queryByText('Author 0')).not.toBeInTheDocument()
expect(onPickImage).not.toHaveBeenCalled() 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 () => { it('FE-PDC-037: credits the picture in play, licence included', async () => {
withPhotos(2) withPhotos(2)
renderColumn({ selectedImageUrl: '/api/maps/place-photo/p0/bytes' }) renderColumn()
expect(await screen.findByText('Author 0')).toBeInTheDocument() expect(await screen.findByText('Author 0')).toBeInTheDocument()
expect(screen.getByText('Author 0').closest('a')).toHaveAttribute( 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/ }) const tile = await screen.findByRole('button', { name: /places.details.pickImage/ })
expect(tile).not.toHaveAttribute('title') 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 licence = screen.getByText('CC BY-SA 4.0', { selector: 'a[href*="creativecommons"]' })
const line = licence.parentElement?.parentElement as HTMLElement expect(licence.closest('p')).toHaveTextContent('Alice')
expect(line).toHaveTextContent('Alice') expect(licence.closest('p')?.textContent).not.toContain('·')
expect(line.textContent).not.toContain('·')
fireEvent.click(tile) fireEvent.click(tile)
expect(onPickImage).toHaveBeenCalledWith(COMMONS_PHOTO.url) 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() 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, ArrowRight,
Bike, Bike,
Building2, Building2,
Check,
ChefHat, ChefHat,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
@@ -32,22 +33,22 @@ import { fs } from '../shared/DialogShell'
import { LABEL } from '../shared/dialogParts' import { LABEL } from '../shared/dialogParts'
import { COLUMN_CARD, ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from './placeDialogParts' import { COLUMN_CARD, ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from './placeDialogParts'
import type { TranslationFn } from '../../types' 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 { interface PlaceDetailsColumnProps {
selection: PlaceDetailsSelection | null selection: PlaceDetailsSelection | null
/** Currently chosen hero image, so the picked tile can show as picked. */ /** Currently chosen hero image, so the picked tile can show as picked. */
selectedImageUrl?: string | null selectedImageUrl?: string
onPickImage: (url: string | null) => void 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 onAdoptDescription: (text: string) => void
/** The looked-up opening hours, once they arrive, so the form can take them over (#2472). */ /** The looked-up opening hours, once they arrive, so the form can take them over (#2472). */
onHours?: (hours: PlaceHours) => void onHours?: (hours: PlaceHours) => void
@@ -74,6 +75,47 @@ interface PlaceDetailsColumnProps {
t: TranslationFn 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' type LoadState = 'idle' | 'loading' | 'ready' | 'error'
/** /**
@@ -119,7 +161,6 @@ export default function PlaceDetailsColumn({
locale = 'en-US', locale = 'en-US',
fluid = false, fluid = false,
variant = 'sheet', variant = 'sheet',
image,
t, t,
}: PlaceDetailsColumnProps): React.ReactElement { }: PlaceDetailsColumnProps): React.ReactElement {
const dialog = variant === 'dialog' const dialog = variant === 'dialog'
@@ -142,8 +183,9 @@ export default function PlaceDetailsColumn({
return return
} }
const cached = readCachedEnrichment(selection, language) const cached = enrichmentCache.get(selectionKey) ?? readSession(selectionKey)
if (cached) { if (cached) {
enrichmentCache.set(selectionKey, cached)
setData(cached) setData(cached)
setState('ready') setState('ready')
return return
@@ -175,7 +217,8 @@ export default function PlaceDetailsColumn({
) )
.then((result) => { .then((result) => {
if (controller.signal.aborted) return if (controller.signal.aborted) return
storeEnrichment(selection, language, result) enrichmentCache.set(selectionKey, result)
writeSession(selectionKey, result)
setData(result) setData(result)
setState('ready') 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'}> <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 {/* 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 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. */} 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 && ( {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} /> <RatingRow rating={data?.rating ?? null} locale={locale} dialog={dialog} />
<OpeningHoursBlock <OpeningHoursBlock
hours={data?.hours ?? null} 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. */ /** The name to put under a picture. Google gives no author, so it gets its own name. */
function PictureBlock({ 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, photos,
chosen, selectedImageUrl,
onPickImage, onPickImage,
own, dialog = false,
dialog,
t, t,
}: { }: {
photos: PlacePhotoCandidate[] photos: PlacePhotoCandidate[]
chosen: ChosenImage selectedImageUrl?: string
onPickImage: (url: string | null) => void onPickImage: (url: string | null) => void
own?: PlaceImageOwn dialog?: boolean
dialog: boolean
t: TranslationFn t: TranslationFn
}): React.ReactElement | null { }): React.ReactElement | null {
if (!offersPictures(photos, own)) return null const [hovered, setHovered] = useState<string | null>(null)
const picker = <PlaceImagePicker photos={photos} chosen={chosen} onPickUrl={onPickImage} own={own} dialog={dialog} t={t} /> 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 ( return (
<Block label={t('places.details.pickImage')} dialog={dialog}> <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> </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. * 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. * 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. // payload, as the desktop dialog and the phone sheet both build them.
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { Assignment, Place, TripFile } from '../../types'; import type { Assignment, Place } from '../../types';
import { import {
DEFAULT_FORM, DEFAULT_FORM,
duplicateName, duplicateName,
@@ -155,18 +155,4 @@ describe('placeFormPayload', () => {
const payload = placeFormPayload(form, [], null, (lat, lng) => ({ at: [lat, lng] })); const payload = placeFormPayload(form, [], null, (lat, lng) => ({ at: [lat, lng] }));
expect(payload.at).toEqual([48.86, null]); 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 type { Assignment, Place, RoadtripStopType } from '@trek/shared'
import { pendingImagePayload, type PendingImagePayload, type PendingPlaceImage } from './placeImageChoice'
import type { PlaceDetailsSelection } from './PlaceDetailsColumn' import type { PlaceDetailsSelection } from './PlaceDetailsColumn'
export interface PlaceFormData { export interface PlaceFormData {
@@ -28,8 +27,7 @@ export interface PlaceFormData {
// Hero image picked from the detail column. Optional and absent from // Hero image picked from the detail column. Optional and absent from
// DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets // 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. // 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
image_url?: string | null
// Day-specific note on the in-context assignment (#2163). Only hydrated when // 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 // 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 // submit payload when unchanged, and useTripPlanner strips it off the place
@@ -310,8 +308,6 @@ export interface PlaceFormPayload extends Omit<PlaceFormData, 'lat' | 'lng' | 'c
category_id: string | null category_id: string | null
/** Files chosen before the place existed: uploaded once the save has given it an id. */ /** Files chosen before the place existed: uploaded once the save has given it an id. */
_pendingFiles?: File[] _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 +322,6 @@ export function placeFormPayload<E extends object = object>(
pendingFiles: File[], pendingFiles: File[],
assignment: Pick<Assignment, 'notes'> | null | undefined, assignment: Pick<Assignment, 'notes'> | null | undefined,
extra?: (lat: number | null, lng: number | null) => E, extra?: (lat: number | null, lng: number | null) => E,
pendingImage: PendingPlaceImage | null = null,
): PlaceFormPayload & E { ): PlaceFormPayload & E {
const lat = form.lat ? Number.parseFloat(form.lat) : null const lat = form.lat ? Number.parseFloat(form.lat) : null
const lng = form.lng ? Number.parseFloat(form.lng) : null const lng = form.lng ? Number.parseFloat(form.lng) : null
@@ -337,7 +332,6 @@ export function placeFormPayload<E extends object = object>(
category_id: form.category_id || null, category_id: form.category_id || null,
...(extra ? extra(lat, lng) : {}), ...(extra ? extra(lat, lng) : {}),
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
_pendingImage: pendingImagePayload(pendingImage),
} as PlaceFormPayload & E } as PlaceFormPayload & E
if (!assignment || (form.assignment_notes ?? '') === (assignment.notes ?? '')) { if (!assignment || (form.assignment_notes ?? '') === (assignment.notes ?? '')) {
delete payload.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 () => { it('FE-PLANNER-PLACEFORM-031: pending files list shows file names after adding', async () => {
render(<PlaceFormModal {...defaultProps} />); 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(); expect(fileInput).toBeTruthy();
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' }); 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 () => { it('FE-PLANNER-PLACEFORM-032: removing a pending file removes it from the list', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />); 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' }); const file = new File(['x'], 'remove-me.jpg', { type: 'image/jpeg' });
fireEvent.change(fileInput, { target: { files: [file] } }); 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 () => { it('FE-PLANNER-PLACEFORM-057: the Attach button opens the hidden file input', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />); 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(() => {}); const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
await user.click(screen.getByRole('button', { name: /Attach/i })); await user.click(screen.getByRole('button', { name: /Attach/i }));
expect(clickSpy).toHaveBeenCalled(); 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 () => { it('FE-PLANNER-PLACEFORM-103: a pending file is removed by its named button', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />); 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' })] }, target: { files: [new File(['x'], 'ticket.pdf', { type: 'application/pdf' })] },
}); });
await user.click(within(screen.getByTestId('pending-files')).getByRole('button', { name: 'Delete' })); await user.click(within(screen.getByTestId('pending-files')).getByRole('button', { name: 'Delete' }));
@@ -8,11 +8,9 @@ import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import CollectionPicker from '../Collections/CollectionPicker' import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn from './PlaceDetailsColumn' import PlaceDetailsColumn from './PlaceDetailsColumn'
import { PendingFiles, PlaceImageSection } from './PlaceFormMedia'
import { usePlaceImageField } from './usePlaceImageField'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Tooltip } from '../shared/Tooltip' 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 { useTranslation } from '../../i18n'
import { PlaceContactFields } from './PlaceContactFields' import { PlaceContactFields } from './PlaceContactFields'
import { weekFromPeriods } from './placeHours' import { weekFromPeriods } from './placeHours'
@@ -43,6 +41,7 @@ import {
} from '../shared/DialogShell' } from '../shared/DialogShell'
import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts' import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts'
import { SoftPill, tintOf } from './planParts' import { SoftPill, tintOf } from './planParts'
import { WHITE_BUTTON } from './placeDialogParts'
import { TimeSection } from './PlaceFormTimeSection' import { TimeSection } from './PlaceFormTimeSection'
// The submit payload mirrors the form, but lat/lng are parsed to numbers and // The submit payload mirrors the form, but lat/lng are parsed to numbers and
@@ -122,11 +121,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const { const {
form, setForm, autoFilledRef, pendingFiles, addFilesFromInput, removeFile, handlePaste, isSaving, setIsSaving, form, setForm, autoFilledRef, pendingFiles, addFilesFromInput, removeFile, handlePaste, isSaving, setIsSaving,
duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange: changeField, pickImage, duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange: changeField, pickImage,
adoptDescription, warnIfDuplicate, pendingImage, stageUpload, stageAttached, removeImage, adoptDescription, warnIfDuplicate,
} = usePlaceForm({ t, toast, canUploadFiles }) } = 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 // 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. // in the head band. Null for a place typed by hand or taken from the saved list.
const [pickedSource, setPickedSource] = useState<string | null>(null) const [pickedSource, setPickedSource] = useState<string | null>(null)
@@ -362,7 +358,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
...(serviceStop ...(serviceStop
? { _serviceStop: serviceStopChoice(serviceStop, lat, lng, serviceStopLeg).placement } ? { _serviceStop: serviceStopChoice(serviceStop, lat, lng, serviceStopLeg).placement }
: {}), : {}),
}), pendingImage) }))
const saved = await onSave(payload) const saved = await onSave(payload)
// Open the Costs editor for the saved place when the user asked to // 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 // create/edit its linked expense — gated on an id, so a create that the
@@ -446,7 +442,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
handleRemoveFile: removeFile, handleRemoveFile: removeFile,
handlePaste, handlePaste,
pickImage, pickImage,
image,
adoptDescription, adoptDescription,
hasTimeError, hasTimeError,
handleSubmit, handleSubmit,
@@ -611,7 +606,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
selection={detailsSelection} selection={detailsSelection}
selectedImageUrl={form.image_url} selectedImageUrl={form.image_url}
onPickImage={S.pickImage} onPickImage={S.pickImage}
image={S.image}
onAdoptDescription={S.adoptDescription} onAdoptDescription={S.adoptDescription}
onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))} onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))}
hasDescription={!!form.description.trim()} hasDescription={!!form.description.trim()}
@@ -885,7 +879,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
maxLength={2000} maxLength={2000}
/> />
{!showDetails && <PlaceImageSection image={S.image} onPickUrl={S.pickImage} t={t} />}
{/* The link and the files side by side, as on a booking. */} {/* The link and the files side by side, as on a booking. */}
<div className={canUploadFiles ? GRID_2 : ''}> <div className={canUploadFiles ? GRID_2 : ''}>
<EditorField label={t('places.formWebsite')} htmlFor={`${fieldId}-website`}> <EditorField label={t('places.formWebsite')} htmlFor={`${fieldId}-website`}>
@@ -1124,3 +1117,44 @@ function MarkdownField({ id, label, value, onChange, placeholder, maxLength }: {
</DialogSection> </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: { mapsApi: {
details: vi.fn().mockResolvedValue({ place: null }), details: vi.fn().mockResolvedValue({ place: null }),
placePhotoCredit: vi.fn().mockResolvedValue({ credit: 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 PlaceInspector from './PlaceInspector';
import { mapsApi } from '../../api/client'; import { mapsApi } from '../../api/client';
import { __clearEnrichmentCacheForTests } from './placeEnrichment';
// ── Shared fixtures ─────────────────────────────────────────────────────────── // ── Shared fixtures ───────────────────────────────────────────────────────────
@@ -102,8 +100,6 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } }); seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } });
vi.mocked(mapsApi.details).mockResolvedValue({ place: null }); vi.mocked(mapsApi.details).mockResolvedValue({ place: null });
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue({ photos: [], facts: [], description: null } as never);
__clearEnrichmentCacheForTests();
}); });
// ── Tests ───────────────────────────────────────────────────────────────────── // ── 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', () => { it('FE-PLANNER-INSPECTOR-049: onUploadImage in trip mode renders the upload-capable avatar', () => {
render(<PlaceInspector {...defaultProps} />); render(<PlaceInspector {...defaultProps} onUploadImage={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Show photos' })).toBeTruthy(); // The place carries no image yet, so the avatar offers "Upload image".
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull(); expect(screen.getByRole('button', { name: 'Upload image' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
}); });
it('FE-PLANNER-INSPECTOR-050: with no picture and nobody to ask for one the avatar stays a plain picture', () => { it('FE-PLANNER-INSPECTOR-050: without onUploadImage the avatar has no upload control', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
render(<PlaceInspector {...defaultProps} />); render(<PlaceInspector {...defaultProps} />);
expect(screen.queryByRole('button', { name: 'Show photos' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
}); });
// ── Track colour (#776) ────────────────────────────────────────────────────── // ── Track colour (#776) ──────────────────────────────────────────────────────
@@ -1422,23 +1415,15 @@ describe('PlaceInspector', () => {
expect(document.querySelector('iframe[src*="tide-widget"]')).toBeNull(); 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 () => { it('FE-PLANNER-INSPECTOR-088: removing the custom image clears image_url through onUpdatePlace', async () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const onUpdatePlace = vi.fn(); const onUpdatePlace = vi.fn();
const onUploadImage = vi.fn(async () => {});
const withImage = buildPlace({ id: 706, name: 'Pictured', image_url: '/uploads/places/x.jpg' }); const withImage = buildPlace({ id: 706, name: 'Pictured', image_url: '/uploads/places/x.jpg' });
render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} />); render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} onUploadImage={onUploadImage} />);
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Remove image' }));
expect(onUpdatePlace).toHaveBeenCalledWith(706, { image_url: null });
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());
}); });
it('FE-PLANNER-INSPECTOR-090: opening hours are read for the selected day, not for today', async () => { 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 () => { it('FE-PLANNER-INSPECTOR-089: picking a file hands it to onUploadImage', async () => {
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue({ const onUploadImage = vi.fn(async () => {});
photos: [{ render(<PlaceInspector {...defaultProps} onUploadImage={onUploadImage} />);
key: 'gp~p0', url: '/api/maps/place-photo/gp~p0/bytes', attribution: 'Alice', license: 'CC BY 4.0', const input = document.querySelector('input[accept*="image"]') as HTMLInputElement;
licenseUrl: null, sourceUrl: 'https://commons.wikimedia.org/wiki/File:T.jpg', source: 'wikimedia', const file = new File(['x'], 'thumb.png', { type: 'image/png' });
}], fireEvent.change(input, { target: { files: [file] } });
facts: [], await waitFor(() => expect(onUploadImage).toHaveBeenCalledWith(place.id, file));
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-098: deselecting and reselecting a place survives a rerender', async () => { 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 { 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 { hoursLines, periodsFromWeek } from './placeHours'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import PlacePhotoGallery from './PlacePhotoGallery' import PlaceAvatarUpload, { pickableImages } from '../shared/PlaceAvatarUpload'
import { BlurredCode } from '../shared/BookingCode' import { BlurredCode } from '../shared/BookingCode'
import PlaceRating from '../shared/StarRating' import PlaceRating from '../shared/StarRating'
import TrackColorPicker from '../shared/TrackColorPicker' import TrackColorPicker from '../shared/TrackColorPicker'
@@ -186,6 +186,10 @@ interface PlaceInspectorProps {
tripMembers?: TripMember[] tripMembers?: TripMember[]
onSetParticipants?: (assignmentId: number, dayId: number, participantIds: number[]) => void onSetParticipants?: (assignmentId: number, dayId: number, participantIds: number[]) => void
onUpdatePlace?: (placeId: number, data: Partial<Place>) => 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. */ /** Cast/clear the current user's star vote (#1435); enables the rating row. */
onRate?: (placeId: number, rating: number | null) => Promise<void> | void onRate?: (placeId: number, rating: number | null) => Promise<void> | void
leftWidth?: number leftWidth?: number
@@ -211,7 +215,7 @@ export default function PlaceInspector({
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null, place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking, assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking,
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment, 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, leftWidth = 0, rightWidth = 0, clearMapPill = false,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive, collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) { }: PlaceInspectorProps) {
@@ -388,6 +392,9 @@ export default function PlaceInspector({
editingName={editingName} nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue} editingName={editingName} nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue}
commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit} commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit}
onUpdatePlace={onUpdatePlace} onUpdatePlace={onUpdatePlace}
onUploadImage={mode === 'trip' && onUpdatePlace ? onUploadImage : undefined}
attachedImages={pickableImages(placeFiles)}
onImageFromFile={mode === 'trip' && onUpdatePlace ? onImageFromFile : undefined}
onClose={onClose} /> onClose={onClose} />
{/* The body scrolls inside the capped card; each block keeps its natural height (#1195). */} {/* 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 handleNameKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
startNameEdit: () => void startNameEdit: () => void
onUpdatePlace?: (placeId: number, data: Partial<Place>) => 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 onClose: () => void
} }
/** /**
* The head band: the avatar with its open/closed ring and photo credit, the * 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 * name (a double-click renames it), the address, and the facts as pills.
* avatar opens the place's photos; its picture is changed in the editor.
*/ */
function InspectorHead({ place, category, openNow, phone, email, rating, ratingCount, price, time, editingName, nameInputRef, function InspectorHead({ place, category, openNow, phone, email, rating, ratingCount, price, time, editingName, nameInputRef,
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onClose }: InspectorHeadProps) { nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onUploadImage, attachedImages, onImageFromFile, onClose }: InspectorHeadProps) {
const { t, locale, language } = useTranslation() const { t, locale } = useTranslation()
const ring = openNow === true ? 'bg-success' : openNow === false ? 'bg-danger' : 'bg-surface-card shadow-sm' const ring = openNow === true ? 'bg-success' : openNow === false ? 'bg-danger' : 'bg-surface-card shadow-sm'
const hasCoords = !!(place.lat && place.lng) const hasCoords = !!(place.lat && place.lng)
const hasPills = openNow !== null || !!category || !!time || rating != null || !!price || place.source === 'dawarich' || !!phone || !!email || hasCoords 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 items-start gap-3.5">
<div className="flex w-[57px] flex-none flex-col items-center"> <div className="flex w-[57px] flex-none flex-col items-center">
<div className={`rounded-full p-[2.5px] ${ring}`}> <div className={`rounded-full p-[2.5px] ${ring}`}>
<PlacePhotoGallery place={place} language={language} label={t('places.openPhotos')} className="rounded-full"> {onUploadImage
<PlaceAvatar place={place} category={category} size={52} /> ? <PlaceAvatarUpload place={place} category={category} size={52}
</PlacePhotoGallery> 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> </div>
<PhotoCredit imageUrl={place.image_url} /> <PhotoCredit imageUrl={place.image_url} />
</div> </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)}
</>
);
}
@@ -1,62 +0,0 @@
// FE-PLANNER-ENRICHCACHE-001 to FE-PLANNER-ENRICHCACHE-005: the session cache the editor's
// details column and the view's photo gallery share, so a place is asked about once.
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
__clearEnrichmentCacheForTests,
cacheKeyFor,
readCachedEnrichment,
storeEnrichment,
type PlaceDetailsSelection,
} from './placeEnrichment';
const SELECTION: PlaceDetailsSelection = { placeId: 'way:1', lat: 50.9, lng: 6.96, name: 'Museum Ludwig' };
const RESULT = { photos: [], facts: [], description: null } as unknown as MapsPlaceEnrichmentResult;
beforeEach(() => {
sessionStorage.clear();
__clearEnrichmentCacheForTests();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('placeEnrichment', () => {
it('FE-PLANNER-ENRICHCACHE-001: the key names the version, the place (or its coordinates) and the language', () => {
expect(cacheKeyFor(SELECTION, 'de')).toBe('enrich_v4_way:1_de');
expect(cacheKeyFor({ lat: 1.5, lng: 2, name: 'Pin' }, 'en')).toBe('enrich_v4_coords:1.5:2_en');
});
it('FE-PLANNER-ENRICHCACHE-002: nothing is cached before it is stored, then both caches hold it', () => {
expect(readCachedEnrichment(SELECTION, 'de')).toBeUndefined();
storeEnrichment(SELECTION, 'de', RESULT);
expect(readCachedEnrichment(SELECTION, 'de')).toBe(RESULT);
expect(JSON.parse(sessionStorage.getItem('enrich_v4_way:1_de') ?? '')).toEqual(RESULT);
// Another language is another answer.
expect(readCachedEnrichment(SELECTION, 'en')).toBeUndefined();
});
it('FE-PLANNER-ENRICHCACHE-003: an answer kept in the session outlives the in-memory cache', () => {
storeEnrichment(SELECTION, 'de', RESULT);
__clearEnrichmentCacheForTests();
expect(readCachedEnrichment(SELECTION, 'de')).toEqual(RESULT);
// Taken back into memory: the session is not read a second time.
sessionStorage.clear();
expect(readCachedEnrichment(SELECTION, 'de')).toEqual(RESULT);
});
it('FE-PLANNER-ENRICHCACHE-004: a broken session entry reads as nothing cached', () => {
sessionStorage.setItem(cacheKeyFor(SELECTION, 'de'), '{not json');
expect(readCachedEnrichment(SELECTION, 'de')).toBeUndefined();
});
it('FE-PLANNER-ENRICHCACHE-005: a full or blocked session storage still keeps the answer in memory', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
expect(() => storeEnrichment(SELECTION, 'de', RESULT)).not.toThrow();
expect(readCachedEnrichment(SELECTION, 'de')).toBe(RESULT);
});
});
@@ -1,77 +0,0 @@
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
/**
* The place an enrichment consumer is describing — the editor's details column
* and the view-mode photo gallery both use this shape.
*/
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>;
}
/**
* 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, and the gallery must reuse the pictures
* the editor already fetched for the same place.
*/
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;
export function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string {
const id = selection.placeId || `coords:${selection.lat}:${selection.lng}`;
return `enrich_v${ENRICH_CACHE_V}_${id}_${language}`;
}
/** The answer if it was already fetched this session, else undefined. */
export function readCachedEnrichment(
selection: PlaceDetailsSelection,
language: string
): MapsPlaceEnrichmentResult | undefined {
const key = cacheKeyFor(selection, language);
const cached = enrichmentCache.get(key) ?? readSession(key);
if (cached) enrichmentCache.set(key, cached);
return cached;
}
export function storeEnrichment(
selection: PlaceDetailsSelection,
language: string,
value: MapsPlaceEnrichmentResult
): void {
const key = cacheKeyFor(selection, language);
enrichmentCache.set(key, value);
writeSession(key, value);
}
@@ -1,65 +0,0 @@
// FE-PLANNER-PLIMGCHOICE-001 to FE-PLANNER-PLIMGCHOICE-006: the editor's picture choice
// as a pure model, read the same way by the desktop dialog and the phone sheet.
import { describe, expect, it } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import { chosenImage, pendingImagePayload, pickableImages, type PendingPlaceImage } from './placeImageChoice';
const CURRENT = '/uploads/places/current.jpg';
describe('pickableImages', () => {
it('FE-PLANNER-PLIMGCHOICE-001: keeps the jpeg, png, gif and webp files and drops the rest', () => {
const jpg = buildTripFile({ mime_type: 'image/jpeg' });
const png = buildTripFile({ mime_type: 'image/png' });
const gif = buildTripFile({ mime_type: 'image/gif' });
const webp = buildTripFile({ mime_type: 'image/webp' });
const heic = buildTripFile({ mime_type: 'image/heic' });
const svg = buildTripFile({ mime_type: 'image/svg+xml' });
const pdf = buildTripFile({ mime_type: 'application/pdf' });
const unknown = buildTripFile({ mime_type: undefined as unknown as string });
expect(pickableImages([jpg, pdf, png, heic, gif, svg, webp, unknown])).toEqual([jpg, png, gif, webp]);
expect(pickableImages([])).toEqual([]);
});
});
describe('chosenImage', () => {
const upload: PendingPlaceImage = { kind: 'upload', file: new File(['x'], 'a.png'), preview: 'blob:a' };
const attached: PendingPlaceImage = { kind: 'attached', file: buildTripFile({ id: 77, mime_type: 'image/png' }) };
it('FE-PLANNER-PLIMGCHOICE-002: a waiting picture wins over whatever the field holds', () => {
expect(chosenImage('https://img.example/1.jpg', upload, CURRENT)).toEqual({ kind: 'upload' });
expect(chosenImage(null, attached, CURRENT)).toEqual({ kind: 'attached', fileId: 77 });
});
it('FE-PLANNER-PLIMGCHOICE-003: null drops the picture, whether the place had one or not', () => {
expect(chosenImage(null, null, CURRENT)).toEqual({ kind: 'none' });
expect(chosenImage(null, null, null)).toEqual({ kind: 'none' });
});
it("FE-PLANNER-PLIMGCHOICE-004: a URL is a source's photo, unless it is the picture the place already has", () => {
expect(chosenImage('https://img.example/1.jpg', null, CURRENT)).toEqual({
kind: 'url',
url: 'https://img.example/1.jpg',
});
expect(chosenImage(CURRENT, null, CURRENT)).toEqual({ kind: 'current' });
});
it('FE-PLANNER-PLIMGCHOICE-005: an untouched field keeps the current picture, or none on a place without one', () => {
expect(chosenImage(undefined, null, CURRENT)).toEqual({ kind: 'current' });
expect(chosenImage(undefined, null, null)).toEqual({ kind: 'none' });
expect(chosenImage(undefined, null, undefined)).toEqual({ kind: 'none' });
expect(chosenImage('', null, CURRENT)).toEqual({ kind: 'current' });
});
});
describe('pendingImagePayload', () => {
it("FE-PLANNER-PLIMGCHOICE-006: the save carries the file itself, or the attached file's id, or nothing", () => {
const file = new File(['x'], 'a.png');
expect(pendingImagePayload({ kind: 'upload', file, preview: 'blob:a' })).toBe(file);
expect(pendingImagePayload({ kind: 'attached', file: buildTripFile({ id: 9, mime_type: 'image/png' }) })).toEqual({
fileId: 9,
});
expect(pendingImagePayload(null)).toBeUndefined();
});
});
@@ -1,57 +0,0 @@
import type { PlacePhotoCandidate } from '@trek/shared';
import type { TripFile } from '../../types';
import type { PlaceImageOwn } from './PlaceImagePicker';
const PICKABLE = /^image\/(jpeg|png|gif|webp)$/;
/** The images among a place's files that can stand as its picture (#1242). */
export function pickableImages(files: readonly TripFile[]): TripFile[] {
return files.filter((f) => PICKABLE.test(f.mime_type || ''));
}
/**
* A picture the form will make the place's own once it is saved: a file from
* the device, or an image already attached to the place. Nothing is sent
* before the save, so cancelling the form leaves the picture as it was, and a
* new place can get one too.
*/
export type PendingPlaceImage = { kind: 'upload'; file: File; preview: string } | { kind: 'attached'; file: TripFile };
/** What the picker shows as chosen: the tile that stands for what the save will do. */
export type ChosenImage =
| { kind: 'current' }
| { kind: 'none' }
| { kind: 'url'; url: string }
| { kind: 'upload' }
| { kind: 'attached'; fileId: number };
/**
* The choice the form holds, read as a tile. `imageUrl` is the form's field:
* undefined keeps the place's picture, null drops it, a URL takes a source's
* photo. A waiting picture wins over all of them.
*/
export function chosenImage(
imageUrl: string | null | undefined,
pending: PendingPlaceImage | null,
currentUrl: string | null | undefined
): ChosenImage {
if (pending) return pending.kind === 'upload' ? { kind: 'upload' } : { kind: 'attached', fileId: pending.file.id };
if (imageUrl === null) return { kind: 'none' };
if (imageUrl) return imageUrl === currentUrl ? { kind: 'current' } : { kind: 'url', url: imageUrl };
return currentUrl ? { kind: 'current' } : { kind: 'none' };
}
/** What travels with the save to put a waiting picture in place: the file, or the attached file's id. */
export type PendingImagePayload = File | { fileId: number };
export function pendingImagePayload(pending: PendingPlaceImage | null): PendingImagePayload | undefined {
if (!pending) return undefined;
return pending.kind === 'upload' ? pending.file : { fileId: pending.file.id };
}
/** Whether the picker has a tile to show: its sources' photos, or a picture of the place's own, or an upload. */
export function offersPictures(photos: readonly PlacePhotoCandidate[], own: PlaceImageOwn | undefined): boolean {
if (photos.length > 0) return true;
if (!own) return false;
return !!own.currentUrl || own.attached.length > 0 || !!own.pendingPreview || own.canUpload;
}
@@ -1,39 +0,0 @@
// FE-PLANNER-PHOTOCREDIT-001 to FE-PLANNER-PHOTOCREDIT-003: who a source's photo is credited to,
// and the one-line caption the gallery shows under it.
import type { PlacePhotoCandidate } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { creditOf, photoCaption, sourceLabelFor } from './placePhotoCredit';
function photo(over: Partial<PlacePhotoCandidate> = {}): PlacePhotoCandidate {
return {
key: 'k',
url: '/api/maps/place-photo/k/bytes',
attribution: 'Alice',
license: 'CC BY-SA 4.0',
licenseUrl: null,
sourceUrl: null,
source: 'wikimedia',
...over,
} as PlacePhotoCandidate;
}
describe('placePhotoCredit', () => {
it('FE-PLANNER-PHOTOCREDIT-001: each provider is named by its proper name', () => {
expect(sourceLabelFor('google')).toBe('Google');
expect(sourceLabelFor('wikipedia')).toBe('Wikipedia');
expect(sourceLabelFor('wikimedia')).toBe('Wikimedia Commons');
});
it('FE-PLANNER-PHOTOCREDIT-002: the author is credited, and the provider when there is none', () => {
expect(creditOf(photo())).toBe('Alice');
expect(creditOf(photo({ attribution: null, source: 'google' }))).toBe('Google');
expect(creditOf(photo({ attribution: '', source: 'wikipedia' }))).toBe('Wikipedia');
});
it('FE-PLANNER-PHOTOCREDIT-003: the caption joins the credit and the licence with a comma, not a dot', () => {
expect(photoCaption(photo())).toBe('Alice, CC BY-SA 4.0');
expect(photoCaption(photo({ attribution: null, license: null, source: 'google' }))).toBe('Google');
expect(photoCaption(photo()).includes('·')).toBe(false);
});
});
@@ -1,22 +0,0 @@
import type { PlacePhotoCandidate } from '@trek/shared';
/** The name to put under a picture. Google gives no author, so it gets its own name. */
export function creditOf(photo: PlacePhotoCandidate): string {
return photo.attribution || sourceLabelFor(photo.source);
}
/**
* The provider a picture came from, for the pictures that hand us no author.
* Proper names, so they are not translated.
*/
export function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
if (source === 'google') return 'Google';
if (source === 'wikipedia') return 'Wikipedia';
return 'Wikimedia Commons';
}
/** Author and licence joined for a single-line caption, with a comma as the rest of the app writes it. */
export function photoCaption(photo: PlacePhotoCandidate): string {
const credit = creditOf(photo);
return photo.license ? `${credit}, ${photo.license}` : credit;
}
@@ -1,6 +1,6 @@
// FE-PLANNER-PLACEACT-001 to -015: the place writes behind both the desktop // FE-PLANNER-PLACEACT-001 to -015: the place writes behind both the desktop
// inspector and the phone place sheet. // inspector and the phone place sheet.
import { act, renderHook } from '@testing-library/react'; import { act, renderHook, waitFor } from '@testing-library/react';
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -8,12 +8,14 @@ import { buildAssignment, buildPlace } from '../../../tests/helpers/factories';
import { resetAllStores } from '../../../tests/helpers/store'; import { resetAllStores } from '../../../tests/helpers/store';
import { assignmentsApi } from '../../api/client'; import { assignmentsApi } from '../../api/client';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { normalizeImageFile } from '../../utils/convertHeic';
import { import {
participantsWith, participantsWith,
participantsWithout, participantsWithout,
placeActions, placeActions,
splitParticipants, splitParticipants,
usePlaceFileUpload, usePlaceFileUpload,
usePlaceImagePick,
} from './usePlaceActions'; } from './usePlaceActions';
vi.mock('../../utils/convertHeic', () => ({ vi.mock('../../utils/convertHeic', () => ({
@@ -206,3 +208,61 @@ describe('usePlaceFileUpload', () => {
expect(result.current.filesExpanded).toBe(false); expect(result.current.filesExpanded).toBe(false);
}); });
}); });
describe('usePlaceImagePick', () => {
it('FE-PLANNER-PLACEACT-013: converts the picked picture, uploads it and clears the input', async () => {
const upload = vi.fn(async (_file: File) => undefined);
const toast = { error: vi.fn() };
const { result } = renderHook(() => usePlaceImagePick(upload, toast));
const { event, target } = pick([new File(['x'], 'photo.heic')]);
await act(async () => {
await result.current.pickImage(event);
});
expect(normalizeImageFile).toHaveBeenCalledTimes(1);
expect(upload.mock.calls[0][0].name).toBe('normalized-photo.heic');
expect(target.value).toBe('');
expect(result.current.busy).toBe(false);
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLACEACT-014: a refused picture is reported and the busy flag drops again', async () => {
let fail: (err: Error) => void = () => undefined;
const upload = vi.fn(() => new Promise<void>((_resolve, reject) => (fail = reject)));
const toast = { error: vi.fn() };
const { result } = renderHook(() => usePlaceImagePick(upload, toast));
let running: Promise<void> = Promise.resolve();
act(() => {
running = result.current.pickImage(pick([new File(['x'], 'a.png')]).event);
});
expect(result.current.busy).toBe(true);
await waitFor(() => expect(upload).toHaveBeenCalled());
await act(async () => {
fail(new Error('unsupported'));
await running;
});
expect(toast.error).toHaveBeenCalledWith('places.imageUploadError');
expect(result.current.busy).toBe(false);
});
it('FE-PLANNER-PLACEACT-015: without an upload or a file the pick only clears the input', async () => {
const upload = vi.fn(async () => undefined);
const noUpload = renderHook(() => usePlaceImagePick(undefined, { error: vi.fn() }));
const nothingPicked = renderHook(() => usePlaceImagePick(upload, { error: vi.fn() }));
const first = pick([new File(['x'], 'a.png')]);
const second = pick([]);
await act(async () => {
await noUpload.result.current.pickImage(first.event);
await nothingPicked.result.current.pickImage(second.event);
});
expect(first.target.value).toBe('');
expect(second.target.value).toBe('');
expect(upload).not.toHaveBeenCalled();
expect(normalizeImageFile).not.toHaveBeenCalled();
act(() => noUpload.result.current.setBusy(true));
expect(noUpload.result.current.busy).toBe(true);
});
});
@@ -4,6 +4,7 @@ import { translateApiError, useTranslation } from '../../i18n';
import type { TripStoreState } from '../../store/tripStore'; import type { TripStoreState } from '../../store/tripStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import type { Place } from '../../types'; import type { Place } from '../../types';
import { normalizeImageFile } from '../../utils/convertHeic';
type Translate = (key: string, params?: Record<string, string | number>) => string; type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -132,3 +133,29 @@ export function usePlaceFileUpload(
return { uploading, filesExpanded, setFilesExpanded, fileInputRef, handleUpload }; return { uploading, filesExpanded, setFilesExpanded, fileInputRef, handleUpload };
} }
/**
* A picture picked as a place's image (#1136): converted from HEIC where needed,
* uploaded, and a failure said in a toast. `busy` also covers whatever else the
* caller does to the image while it runs. Without an upload the pick does nothing.
*/
export function usePlaceImagePick(upload: ((file: File) => Promise<unknown>) | undefined, toast: PlaceActionToast) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const pickImage = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !upload) return;
setBusy(true);
try {
await upload(await normalizeImageFile(file));
} catch (err: unknown) {
toast.error(translateApiError(t, err, 'places.imageUploadError'));
} finally {
setBusy(false);
}
};
return { busy, setBusy, pickImage };
}
@@ -1,11 +1,9 @@
// FE-PLANNER-PLFORM-001 to FE-PLANNER-PLFORM-019: the place form state behind the desktop // FE-PLANNER-PLFORM-001 to FE-PLANNER-PLFORM-014: the place form state behind the desktop
// dialog and the phone sheet, its waiting picture, and the inline new category of both // dialog and the phone sheet, and the inline new category of both ('dialog' and 'sheet').
// ('dialog' and 'sheet').
import { act, renderHook } from '@testing-library/react'; import { act, renderHook } from '@testing-library/react';
import type React from 'react'; import type React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import type { Category, Place } from '../../types'; import type { Category, Place } from '../../types';
import { DEFAULT_FORM } from './PlaceFormModal.helpers'; import { DEFAULT_FORM } from './PlaceFormModal.helpers';
import { useNewCategory, usePlaceForm, type NewCategoryOptions } from './usePlaceForm'; import { useNewCategory, usePlaceForm, type NewCategoryOptions } from './usePlaceForm';
@@ -165,88 +163,6 @@ function category(id: number): Category {
return { id, name: 'Food', color: '#6366f1', icon: 'MapPin' } as unknown as Category; return { id, name: 'Food', color: '#6366f1', icon: 'MapPin' } as unknown as Category;
} }
describe('usePlaceForm, the waiting picture', () => {
let previews: number;
let createObjectURL: MockInstance<typeof URL.createObjectURL>;
let revokeObjectURL: MockInstance<typeof URL.revokeObjectURL>;
beforeEach(() => {
previews = 0;
createObjectURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:preview-${++previews}`);
revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('FE-PLANNER-PLFORM-015: nothing waits on a fresh form', () => {
const { result } = setup();
expect(result.current.pendingImage).toBeNull();
});
it('FE-PLANNER-PLFORM-016: a picture from the device waits with its preview and keeps the field untouched', () => {
const { result } = setup();
act(() => result.current.pickImage('https://img.example/1.jpg'));
const picture = file('front.png', 'image/png');
act(() => result.current.stageUpload(picture));
expect(createObjectURL).toHaveBeenCalledWith(picture);
expect(result.current.pendingImage).toEqual({ kind: 'upload', file: picture, preview: 'blob:preview-1' });
// A source's photo picked before gives way to it.
expect(result.current.form.image_url).toBeUndefined();
});
it('FE-PLANNER-PLFORM-017: replacing the waiting upload frees its preview, whatever replaces it', () => {
const { result } = setup();
act(() => result.current.stageUpload(file('a.png', 'image/png')));
act(() => result.current.stageUpload(file('b.png', 'image/png')));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-1');
expect(result.current.pendingImage).toMatchObject({ kind: 'upload', preview: 'blob:preview-2' });
const attached = buildTripFile({ id: 8, mime_type: 'image/png' });
act(() => result.current.stageAttached(attached));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-2');
expect(result.current.pendingImage).toEqual({ kind: 'attached', file: attached });
expect(result.current.form.image_url).toBeUndefined();
// An attached image has no preview of ours to free.
revokeObjectURL.mockClear();
act(() => result.current.pickImage('https://img.example/2.jpg'));
expect(revokeObjectURL).not.toHaveBeenCalled();
expect(result.current.pendingImage).toBeNull();
expect(result.current.form.image_url).toBe('https://img.example/2.jpg');
});
it('FE-PLANNER-PLFORM-018: removing the picture drops what waits and empties the field for the save', () => {
const { result } = setup();
act(() => result.current.stageUpload(file('a.png', 'image/png')));
act(() => result.current.removeImage());
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-1');
expect(result.current.pendingImage).toBeNull();
expect(result.current.form.image_url).toBeNull();
// A fresh opening starts without a waiting picture as well.
act(() => result.current.stageUpload(file('b.png', 'image/png')));
act(() => result.current.openForm(DEFAULT_FORM, null, null));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-2');
expect(result.current.pendingImage).toBeNull();
});
it('FE-PLANNER-PLFORM-019: closing the form frees the preview still waiting, and only that one', () => {
const { result, unmount } = setup();
act(() => result.current.stageUpload(file('a.png', 'image/png')));
revokeObjectURL.mockClear();
unmount();
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-1');
const empty = setup();
revokeObjectURL.mockClear();
empty.unmount();
expect(revokeObjectURL).not.toHaveBeenCalled();
});
});
describe('useNewCategory', () => { describe('useNewCategory', () => {
function setupCategory(options: Pick<NewCategoryOptions, 'variant' | 'create'>) { function setupCategory(options: Pick<NewCategoryOptions, 'variant' | 'create'>) {
const onCreated = vi.fn(); const onCreated = vi.fn();
+4 -45
View File
@@ -1,8 +1,8 @@
import type React from 'react'; import type React from 'react';
import { useEffect, useRef, useState } from 'react'; import { useRef, useState } from 'react';
import type { useTranslation } from '../../i18n'; import type { useTranslation } from '../../i18n';
import type { Category, TripFile } from '../../types'; import type { Category } from '../../types';
import { offlineOr } from '../../utils/needsConnection'; import { offlineOr } from '../../utils/needsConnection';
import type { useToast } from '../shared/Toast'; import type { useToast } from '../shared/Toast';
import type { PlaceDetailsSelection } from './PlaceDetailsColumn'; import type { PlaceDetailsSelection } from './PlaceDetailsColumn';
@@ -15,7 +15,6 @@ import {
type PlacePrefill, type PlacePrefill,
type ResultField, type ResultField,
} from './PlaceFormModal.helpers'; } from './PlaceFormModal.helpers';
import type { PendingPlaceImage } from './placeImageChoice';
type Translate = ReturnType<typeof useTranslation>['t']; type Translate = ReturnType<typeof useTranslation>['t'];
type Toast = ReturnType<typeof useToast>; type Toast = ReturnType<typeof useToast>;
@@ -41,20 +40,6 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
// user's and survives. See mergeResult. // user's and survives. See mergeResult.
const autoFilledRef = useRef<Set<ResultField>>(new Set()); const autoFilledRef = useRef<Set<ResultField>>(new Set());
const [pendingFiles, setPendingFiles] = useState<File[]>([]); const [pendingFiles, setPendingFiles] = useState<File[]>([]);
// The picture that becomes the place's own when the form is saved.
const [pendingImage, setPendingImageState] = useState<PendingPlaceImage | null>(null);
const previewRef = useRef<string | null>(null);
const setPendingImage = (next: PendingPlaceImage | null) => {
if (previewRef.current) URL.revokeObjectURL(previewRef.current);
previewRef.current = next?.kind === 'upload' ? next.preview : null;
setPendingImageState(next);
};
useEffect(
() => () => {
if (previewRef.current) URL.revokeObjectURL(previewRef.current);
},
[]
);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null); const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null);
// What the details column is describing. Null until there is a place to describe. // What the details column is describing. Null until there is a place to describe.
@@ -74,7 +59,6 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
autoFilledRef.current = openingAutoFilled(place, prefill); autoFilledRef.current = openingAutoFilled(place, prefill);
setDetailsSelection(openingDetailsSelection(place, prefill)); setDetailsSelection(openingDetailsSelection(place, prefill));
setPendingFiles([]); setPendingFiles([]);
setPendingImage(null);
setDuplicateWarning(null); setDuplicateWarning(null);
}; };
@@ -107,29 +91,8 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
} }
}; };
/** A source's photo picked in the details column, or the place's own picture again. */ /** A hero image picked in the details column, or none. */
const pickImage = (url: string | null) => { const pickImage = (url: string | null) => setForm((prev) => ({ ...prev, image_url: url ?? undefined }));
setPendingImage(null);
setForm((prev) => ({ ...prev, image_url: url ?? undefined }));
};
/** A picture from the device, shown at once and uploaded when the form is saved. */
const stageUpload = (file: File) => {
setPendingImage({ kind: 'upload', file, preview: URL.createObjectURL(file) });
setForm((prev) => ({ ...prev, image_url: undefined }));
};
/** An image already attached to the place, made its picture when the form is saved. */
const stageAttached = (file: TripFile) => {
setPendingImage({ kind: 'attached', file });
setForm((prev) => ({ ...prev, image_url: undefined }));
};
/** No picture at all: the save drops the place's own one. */
const removeImage = () => {
setPendingImage(null);
setForm((prev) => ({ ...prev, image_url: null }));
};
/** The details column's description, taken over into the form. */ /** The details column's description, taken over into the form. */
const adoptDescription = (text: string) => setForm((prev) => ({ ...prev, description: text })); const adoptDescription = (text: string) => setForm((prev) => ({ ...prev, description: text }));
@@ -164,10 +127,6 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
openForm, openForm,
handleChange, handleChange,
pickImage, pickImage,
pendingImage,
stageUpload,
stageAttached,
removeImage,
adoptDescription, adoptDescription,
warnIfDuplicate, warnIfDuplicate,
}; };
@@ -1,227 +0,0 @@
// FE-PLANNER-PLGALLERY-001 to FE-PLANNER-PLGALLERY-011: the pictures of one place for the
// view's full screen gallery: its own picture first, the sources' photos asked for on
// open, the shared session cache, and an answer that comes back for a place already left.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { MapsPlaceEnrichmentResult, PlacePhotoCandidate } 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 type { Place } from '../../types';
import { __clearEnrichmentCacheForTests, readCachedEnrichment, storeEnrichment } from './placeEnrichment';
import { usePlaceGallery } from './usePlaceGallery';
const placeEnrichment = vi.fn();
vi.mock('../../repo/placePictureRepo', () => ({
placePictureRepo: { enrichment: (...a: unknown[]) => placeEnrichment(...a) },
}));
// One toast for every render, the way the real hook memoises it.
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const CUSTOM = '/uploads/places/own.jpg';
const PHOTO: PlacePhotoCandidate = {
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:A.jpg',
source: 'wikimedia',
} as PlacePhotoCandidate;
const GOOGLE: PlacePhotoCandidate = {
key: 'gp~p1',
url: '/api/maps/place-photo/gp~p1/bytes',
attribution: null,
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'google',
} as PlacePhotoCandidate;
const result = (photos: PlacePhotoCandidate[]) =>
({ photos, facts: [], description: null }) as unknown as MapsPlaceEnrichmentResult;
const LAKE = buildPlace({ id: 1, name: 'Lake', lat: 47.5, lng: 11.2, google_place_id: 'gp-lake' });
const CASTLE = buildPlace({ id: 2, name: 'Castle', lat: 48.1, lng: 11.6, osm_id: 'way:2' });
function deferred<T>() {
let resolve: (v: T) => void = () => {};
let reject: (e: unknown) => void = () => {};
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function setup(place: Place, language = 'de') {
return renderHook(({ p, lang }) => usePlaceGallery(p, lang), { initialProps: { p: place, lang: language } });
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { placesEnrichEnabled: true });
placeEnrichment.mockReset();
placeEnrichment.mockResolvedValue(result([PHOTO]));
sessionStorage.clear();
__clearEnrichmentCacheForTests();
Object.values(toast).forEach((fn) => fn.mockClear());
});
describe('usePlaceGallery', () => {
it('FE-PLANNER-PLGALLERY-001: it can open with a picture of its own, or with coordinates the sources can be asked about', () => {
expect(setup({ ...LAKE, image_url: CUSTOM }).result.current.canOpen).toBe(true);
expect(setup(LAKE).result.current.canOpen).toBe(true);
expect(setup({ ...LAKE, lat: null, lng: null } as unknown as Place).result.current.canOpen).toBe(false);
expect(setup({ ...LAKE, lat: 'north' as unknown as number }).result.current.canOpen).toBe(false);
seedStore(useAuthStore, { placesEnrichEnabled: false });
expect(setup(LAKE).result.current.canOpen).toBe(false);
expect(setup({ ...LAKE, image_url: CUSTOM }).result.current.canOpen).toBe(true);
});
it('FE-PLANNER-PLGALLERY-002: with the sources switched off its own picture opens at once, alone', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const { result: r } = setup({ ...LAKE, image_url: CUSTOM });
expect(r.current.photos).toEqual([{ id: 'custom', src: CUSTOM, caption: null, captionHref: null }]);
act(() => r.current.open());
expect(r.current.isOpen).toBe(true);
expect(placeEnrichment).not.toHaveBeenCalled();
act(() => r.current.close());
expect(r.current.isOpen).toBe(false);
});
it('FE-PLANNER-PLGALLERY-003: nothing of its own and nobody to ask says so instead of opening', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const { result: r } = setup(LAKE);
act(() => r.current.open());
expect(r.current.isOpen).toBe(false);
expect(toast.info).toHaveBeenCalledWith('places.details.noPhotos');
});
it('FE-PLANNER-PLGALLERY-004: the sources are asked on open, and their photos join with a linked credit', async () => {
const pending = deferred<MapsPlaceEnrichmentResult>();
placeEnrichment.mockReturnValue(pending.promise);
const { result: r } = setup(LAKE);
expect(placeEnrichment).not.toHaveBeenCalled();
act(() => r.current.open());
expect(r.current.loading).toBe(true);
expect(r.current.isOpen).toBe(false);
expect(placeEnrichment).toHaveBeenCalledWith({
placeId: 'gp-lake',
lat: 47.5,
lng: 11.2,
name: 'Lake',
lang: 'de',
});
await act(async () => pending.resolve(result([PHOTO, GOOGLE])));
expect(r.current.loading).toBe(false);
expect(r.current.isOpen).toBe(true);
expect(r.current.photos).toEqual([
{ id: 'gp~p0', src: PHOTO.url, caption: 'Alice, CC BY 4.0', captionHref: PHOTO.sourceUrl },
{ id: 'gp~p1', src: GOOGLE.url, caption: 'Google', captionHref: null },
]);
// Kept for the editor's details column and the next open.
expect(readCachedEnrichment({ placeId: 'gp-lake', lat: 47.5, lng: 11.2, name: 'Lake' }, 'de')).toBeTruthy();
});
it('FE-PLANNER-PLGALLERY-005: a second open uses the answer it already has', async () => {
const { result: r } = setup(LAKE);
act(() => r.current.open());
await waitFor(() => expect(r.current.isOpen).toBe(true));
act(() => r.current.close());
act(() => r.current.open());
expect(r.current.isOpen).toBe(true);
expect(placeEnrichment).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLGALLERY-006: its own picture leads, and a source photo of the same picture is not shown twice', async () => {
placeEnrichment.mockResolvedValue(result([PHOTO, GOOGLE]));
const { result: r } = setup({ ...LAKE, image_url: PHOTO.url });
act(() => r.current.open());
// Open at once on its own picture, while the sources are still asked.
expect(r.current.isOpen).toBe(true);
expect(r.current.loading).toBe(true);
await waitFor(() => expect(r.current.loading).toBe(false));
expect(r.current.photos.map((p) => p.id)).toEqual(['custom', 'gp~p1']);
});
it('FE-PLANNER-PLGALLERY-007: when the sources have nothing either, it says so, the second time too', async () => {
placeEnrichment.mockResolvedValue(result([]));
const { result: r } = setup(CASTLE);
act(() => r.current.open());
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('places.details.noPhotos'));
expect(r.current.isOpen).toBe(false);
// The OSM id is what the server is asked about when there is no Google one.
expect(placeEnrichment.mock.calls[0][0]).toMatchObject({ placeId: 'way:2' });
act(() => r.current.open());
expect(toast.info).toHaveBeenCalledTimes(2);
expect(placeEnrichment).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLGALLERY-008: an answer this session already holds opens without asking', () => {
storeEnrichment({ placeId: 'gp-lake', lat: 47.5, lng: 11.2, name: 'Lake' }, 'de', result([PHOTO]));
const { result: r } = setup(LAKE);
act(() => r.current.open());
expect(r.current.isOpen).toBe(true);
expect(r.current.loading).toBe(false);
expect(placeEnrichment).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLGALLERY-009: a failed request is reported, unless its own picture is already on screen', async () => {
placeEnrichment.mockRejectedValue(new Error('network'));
const bare = setup(LAKE);
act(() => bare.result.current.open());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('places.details.error'));
expect(bare.result.current.loading).toBe(false);
bare.unmount();
toast.error.mockClear();
const pictured = setup({ ...CASTLE, image_url: CUSTOM });
act(() => pictured.result.current.open());
await waitFor(() => expect(pictured.result.current.loading).toBe(false));
expect(pictured.result.current.isOpen).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLGALLERY-010: switching place while the sources answer drops that answer: nothing opens, nothing joins', async () => {
const pending = deferred<MapsPlaceEnrichmentResult>();
placeEnrichment.mockReturnValue(pending.promise);
const { result: r, rerender } = setup(LAKE);
act(() => r.current.open());
expect(r.current.loading).toBe(true);
rerender({ p: CASTLE, lang: 'de' });
expect(r.current.loading).toBe(false);
await act(async () => pending.resolve(result([PHOTO])));
expect(r.current.isOpen).toBe(false);
expect(r.current.loading).toBe(false);
expect(r.current.photos).toEqual([]);
expect(toast.error).not.toHaveBeenCalled();
// The answer still belongs to the first place, so it is kept for it.
expect(readCachedEnrichment({ placeId: 'gp-lake', lat: 47.5, lng: 11.2, name: 'Lake' }, 'de')).toBeTruthy();
});
it('FE-PLANNER-PLGALLERY-011: a failure that arrives after the switch is not reported over the next place', async () => {
const pending = deferred<MapsPlaceEnrichmentResult>();
placeEnrichment.mockReturnValue(pending.promise);
const { result: r, rerender } = setup(LAKE);
act(() => r.current.open());
act(() => r.current.close());
rerender({ p: CASTLE, lang: 'de' });
await act(async () => pending.reject(new Error('network')));
expect(toast.error).not.toHaveBeenCalled();
expect(r.current.isOpen).toBe(false);
});
});
@@ -1,136 +0,0 @@
import type { PlacePhotoCandidate } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { translateApiError, useTranslation } from '../../i18n';
import { placePictureRepo } from '../../repo/placePictureRepo';
import { useAuthStore } from '../../store/authStore';
import type { Place } from '../../types';
import { useToast } from '../shared/Toast';
import { readCachedEnrichment, storeEnrichment, type PlaceDetailsSelection } from './placeEnrichment';
import { photoCaption } from './placePhotoCredit';
/** What the lightbox renders; mirrors Journey/PhotoLightbox's private photo shape. */
interface GalleryPhoto {
id: string;
src: string;
caption: string | null;
/** The photo's source page, which the credit links to. */
captionHref: string | null;
}
interface UsePlaceGallery {
photos: GalleryPhoto[];
open: () => void;
close: () => void;
isOpen: boolean;
/** Loading enrichment; the trigger can show a spinner while it waits. */
loading: boolean;
/** False when there is no picture and nobody who could supply one. */
canOpen: boolean;
}
/**
* The pictures of one place, for the full-screen view-mode gallery.
*
* The custom image (if any) leads, then the provider candidates the editor's
* details column already uses. Enrichment is fetched lazily on open: fanning
* out to Google/Wikimedia for every place a user merely selects is not worth
* it, and the shared session cache makes a second open instant.
*/
export function usePlaceGallery(place: Place, language: string): UsePlaceGallery {
const placesEnrichEnabled = useAuthStore((s) => s.placesEnrichEnabled);
const { t } = useTranslation();
const toast = useToast();
// Each piece of state names the place it belongs to: a different place reads
// as nothing loaded, nothing open, so the previous answer never flashes.
const [candidatesFor, setCandidatesFor] = useState<{ placeId: number; list: PlacePhotoCandidate[] } | null>(null);
const [openFor, setOpenFor] = useState<number | null>(null);
const [loadingFor, setLoadingFor] = useState<number | null>(null);
const placeId = place.id;
const candidates = candidatesFor?.placeId === placeId ? candidatesFor.list : null;
const isOpen = openFor === placeId;
const loading = loadingFor === placeId;
// The place on screen now, for an answer that arrives after the user moved on.
const shownRef = useRef(placeId);
useEffect(() => {
shownRef.current = placeId;
}, [placeId]);
const selection = useMemo<PlaceDetailsSelection | null>(() => {
if (place.lat == null || place.lng == null) return null;
const lat = Number(place.lat);
const lng = Number(place.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return {
placeId: place.google_place_id || place.amap_poi_id || place.osm_id || undefined,
lat,
lng,
name: place.name || '',
};
}, [place.google_place_id, place.amap_poi_id, place.osm_id, place.lat, place.lng, place.name]);
const photos = useMemo<GalleryPhoto[]>(() => {
const list: GalleryPhoto[] = [];
if (place.image_url) list.push({ id: 'custom', src: place.image_url, caption: null, captionHref: null });
for (const candidate of candidates ?? []) {
if (list.some((p) => p.src === candidate.url)) continue;
list.push({
id: candidate.key,
src: candidate.url,
caption: photoCaption(candidate),
captionHref: candidate.sourceUrl ?? null,
});
}
return list;
}, [place.image_url, candidates]);
const canOpen = !!place.image_url || (placesEnrichEnabled && !!selection);
const close = useCallback(() => setOpenFor(null), []);
const open = useCallback(() => {
// The custom image is already on screen — open now and let any provider
// pictures join it as they arrive.
if (place.image_url) setOpenFor(placeId);
if (!placesEnrichEnabled || !selection) {
if (!place.image_url) toast.info(t('places.details.noPhotos'));
return;
}
if (candidates !== null) {
if (place.image_url || candidates.length) setOpenFor(placeId);
else toast.info(t('places.details.noPhotos'));
return;
}
const apply = (list: PlacePhotoCandidate[]) => {
setCandidatesFor({ placeId, list });
if (place.image_url || list.length) setOpenFor(placeId);
else toast.info(t('places.details.noPhotos'));
};
const cached = readCachedEnrichment(selection, language);
if (cached) {
apply(cached.photos);
return;
}
// An answer for a place the user has left is kept for that place, but opens nothing and says nothing.
const current = () => shownRef.current === placeId;
setLoadingFor(placeId);
placePictureRepo
.enrichment({ ...selection, lang: language })
.then((result) => {
storeEnrichment(selection, language, result);
if (current()) apply(result.photos);
else setCandidatesFor({ placeId, list: result.photos });
})
.catch((err: unknown) => {
if (current() && !place.image_url) toast.error(translateApiError(t, err, 'places.details.error'));
})
.finally(() => {
setLoadingFor((now) => (now === placeId ? null : now));
});
}, [place.image_url, placeId, placesEnrichEnabled, selection, candidates, language, t, toast]);
return { photos, open, close, isOpen, loading, canOpen };
}
@@ -1,106 +0,0 @@
// FE-PLANNER-PLIMGFIELD-001 to FE-PLANNER-PLIMGFIELD-004: the editor's picture field the
// desktop dialog and the phone sheet share: the place's attached images, the waiting
// picture's preview, and which tile stands for the choice.
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildReservation, buildTripFile } from '../../../tests/helpers/factories';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTripStore } from '../../store/tripStore';
import type { TripFile } from '../../types';
import type { PendingPlaceImage } from './placeImageChoice';
import { usePlaceImageField } from './usePlaceImageField';
const PHOTO = buildTripFile({ id: 1, place_id: 5, mime_type: 'image/jpeg', original_name: 'front.jpg' });
const PDF = buildTripFile({ id: 2, place_id: 5, mime_type: 'application/pdf' });
const ELSEWHERE = buildTripFile({ id: 3, place_id: 6, mime_type: 'image/png' });
const ON_BOOKING = buildTripFile({
id: 4,
place_id: null,
reservation_id: 90,
mime_type: 'image/webp',
} as Partial<TripFile>);
const handlers = () => ({ stageUpload: vi.fn(), stageAttached: vi.fn(), removeImage: vi.fn() });
beforeEach(() => {
resetAllStores();
useTripStore.setState({
files: [PHOTO, PDF, ELSEWHERE, ON_BOOKING],
reservations: [buildReservation({ id: 90, place_id: 5 })],
} as never);
});
describe('usePlaceImageField', () => {
it("FE-PLANNER-PLIMGFIELD-001: offers the place's attached images, its booking's included, and its current picture", () => {
const h = handlers();
const { result } = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: '/uploads/places/5.jpg' },
imageUrl: undefined,
pendingImage: null,
canUploadFiles: true,
...h,
})
);
expect(result.current.own.attached).toEqual([PHOTO, ON_BOOKING]);
expect(result.current.own.currentUrl).toBe('/uploads/places/5.jpg');
expect(result.current.own.pendingPreview).toBeNull();
expect(result.current.own.canUpload).toBe(true);
expect(result.current.own.onUpload).toBe(h.stageUpload);
expect(result.current.own.onPickAttached).toBe(h.stageAttached);
expect(result.current.own.onRemove).toBe(h.removeImage);
expect(result.current.chosen).toEqual({ kind: 'current' });
});
it('FE-PLANNER-PLIMGFIELD-002: a new place has nothing attached and no picture, but can still be given one', () => {
const { result } = renderHook(() =>
usePlaceImageField({ place: null, imageUrl: undefined, pendingImage: null, canUploadFiles: true, ...handlers() })
);
expect(result.current.own.attached).toEqual([]);
expect(result.current.own.currentUrl).toBeNull();
expect(result.current.own.canUpload).toBe(true);
expect(result.current.chosen).toEqual({ kind: 'none' });
});
it('FE-PLANNER-PLIMGFIELD-003: a waiting upload shows as its preview and is the choice', () => {
const pending: PendingPlaceImage = { kind: 'upload', file: new File(['x'], 'a.png'), preview: 'blob:a' };
const { result } = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: null },
imageUrl: undefined,
pendingImage: pending,
canUploadFiles: false,
...handlers(),
})
);
expect(result.current.own.pendingPreview).toBe('blob:a');
expect(result.current.own.canUpload).toBe(false);
expect(result.current.chosen).toEqual({ kind: 'upload' });
});
it('FE-PLANNER-PLIMGFIELD-004: a waiting attached image has no preview, and a dropped picture reads as none', () => {
const attached = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: '/uploads/places/5.jpg' },
imageUrl: undefined,
pendingImage: { kind: 'attached', file: PHOTO },
canUploadFiles: true,
...handlers(),
})
);
expect(attached.result.current.own.pendingPreview).toBeNull();
expect(attached.result.current.chosen).toEqual({ kind: 'attached', fileId: 1 });
const dropped = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: '/uploads/places/5.jpg' },
imageUrl: null,
pendingImage: null,
canUploadFiles: true,
...handlers(),
})
);
expect(dropped.result.current.chosen).toEqual({ kind: 'none' });
});
});

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