mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1487ad97f1 | |||
| fc27b254bb | |||
| 658536c217 | |||
| 00018959c6 | |||
| 117af4a8a7 | |||
| 84e18e4954 | |||
| 090e754077 | |||
| 4322159d8e | |||
| bdf5767a5f | |||
| e8b071aecd | |||
| f128346a54 | |||
| 5bf6af7afc | |||
| 1a941a6f1d | |||
| 508e17bc11 | |||
| 4d00858b8a | |||
| 7e5b23808d | |||
| bfcc205f05 | |||
| a6a9f3bbdd | |||
| 5651c11803 | |||
| ff749611c6 | |||
| 49f7a447d3 | |||
| ea5447056b | |||
| baec23cf73 | |||
| a202f2dc95 | |||
| a2951f2c99 | |||
| 6395ef655f | |||
| cbf9325e7e | |||
| a32465ad32 | |||
| 01abb90797 | |||
| bce74d6c56 | |||
| 75d0593198 | |||
| 82b8eb1067 | |||
| ce9c94a77b | |||
| 7a7876b9f4 | |||
| 9b8626622f | |||
| dad2a3d523 | |||
| b3180b898e | |||
| 81047917a1 | |||
| 31dba65906 | |||
| 770fb2a2b3 | |||
| 8c7fc989dc | |||
| 58204e72bd | |||
| 53cefba3f3 | |||
| 46613ada23 | |||
| 5d334d7ec8 | |||
| cd60d4d859 | |||
| 454fc1a5f4 | |||
| 767d91146a | |||
| c6b3f609b7 | |||
| 7b50b12fe6 | |||
| d8f7143b4c | |||
| d206435af1 | |||
| d8213bfa42 | |||
| 8c0707f34c | |||
| 8c6f5deab2 | |||
| 23c446b7e9 | |||
| 8ec560884e | |||
| 269fce0331 | |||
| 72661196ee | |||
| fabefed39f | |||
| 507d4f73bc | |||
| 6e43490ba0 | |||
| 79fb71bc92 | |||
| beb2c0c258 | |||
| bebfeeff97 | |||
| 35566690e9 | |||
| 357bcb72e3 | |||
| 47e04b506f | |||
| 9373667084 | |||
| 87dc70a4c0 | |||
| 5b6de2bb16 | |||
| 338548bf7f | |||
| a94464a953 | |||
| 88e6f332d3 | |||
| d07b8ba56e | |||
| e9f7d5e77b | |||
| 9a7c520106 | |||
| 5ccd4674ac | |||
| d1b8cb20a3 | |||
| dd2431db97 | |||
| 746f53ff7e | |||
| 7f9a4da52a | |||
| 8043b7952d | |||
| e198392060 | |||
| deea669e28 | |||
| d6b624e400 | |||
| 263d650460 | |||
| a3fc696510 | |||
| 9f6f62c6ae |
@@ -53,3 +53,54 @@ 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 |
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ 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 }}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ 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.
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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: [] };
|
||||||
|
}
|
||||||
@@ -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('button', { name, exact: true })
|
const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { 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).locator('.st-tabs button').filter({ hasText: 'Entries' }).click()
|
await sidePanel(p).getByRole('tab', { name: /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)
|
||||||
},
|
},
|
||||||
|
|||||||
+28
-1
@@ -47,14 +47,41 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -81,7 +108,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.5",
|
"zod": "^4.6.7",
|
||||||
"zustand": "^5.0.15"
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
{
|
{
|
||||||
"src": {
|
"src": {
|
||||||
"@typescript-eslint/no-non-null-assertion": 404,
|
"@typescript-eslint/no-non-null-assertion": 385,
|
||||||
"@typescript-eslint/no-misused-promises": 854,
|
"@typescript-eslint/no-misused-promises": 849,
|
||||||
"no-console": 88,
|
"no-console": 88,
|
||||||
"complexity": 226,
|
"complexity": 222,
|
||||||
"@typescript-eslint/no-explicit-any": 146,
|
"@typescript-eslint/no-explicit-any": 146,
|
||||||
"trek/no-swallowed-catch": 131,
|
"trek/no-swallowed-catch": 130,
|
||||||
"max-params": 33,
|
"max-params": 32,
|
||||||
"trek/store-without-selector": 67,
|
"trek/store-without-selector": 67,
|
||||||
"react-hooks/refs": 557,
|
"react-hooks/refs": 556,
|
||||||
"react-hooks/set-state-in-effect": 195,
|
"react-hooks/set-state-in-effect": 194,
|
||||||
"@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": 126,
|
"@typescript-eslint/no-unused-vars": 124,
|
||||||
"react-hooks/immutability": 28,
|
"react-hooks/immutability": 28,
|
||||||
"max-lines-per-function": 132,
|
"max-lines-per-function": 130,
|
||||||
"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": 46,
|
"react-refresh/only-export-components": 45,
|
||||||
"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": 491,
|
"@typescript-eslint/no-explicit-any": 490,
|
||||||
"@typescript-eslint/no-non-null-assertion": 1932,
|
"@typescript-eslint/no-non-null-assertion": 1931,
|
||||||
"@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,
|
||||||
|
|||||||
@@ -69,6 +69,15 @@
|
|||||||
"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"],
|
||||||
@@ -592,7 +601,6 @@
|
|||||||
"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"]
|
||||||
|
|||||||
@@ -1123,8 +1123,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -117,11 +117,6 @@ 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}',
|
||||||
@@ -144,6 +139,13 @@ 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_:-]+)+$/;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"packing.ts": 6,
|
"packing.ts": 6,
|
||||||
"pdf.ts": 1,
|
"pdf.ts": 1,
|
||||||
"perm.ts": 4,
|
"perm.ts": 4,
|
||||||
"places.ts": 10,
|
"places.ts": 9,
|
||||||
"planner.ts": 53,
|
"planner.ts": 53,
|
||||||
"reservations.ts": 21,
|
"reservations.ts": 21,
|
||||||
"roadtrip.ts": 36,
|
"roadtrip.ts": 36,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -116,7 +116,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"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,
|
||||||
@@ -52,13 +51,12 @@
|
|||||||
"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/SpreadView.tsx": 8,
|
"components/Studio/PhotoView.tsx": 4,
|
||||||
"components/Studio/StudioCanvas.tsx": 22,
|
"components/Studio/SpreadView.tsx": 4,
|
||||||
|
"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,
|
||||||
@@ -73,7 +71,6 @@
|
|||||||
"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,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"pages/tripPlanner/PlanMapStage.tsx": 4,
|
"components/Studio/LineHandles.tsx": 1,
|
||||||
"mobile/components/trek3d/MascotClip.tsx": 1
|
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||||
|
"components/Studio/inspector/FocalPad.tsx": 1,
|
||||||
|
"mobile/components/trek3d/MascotClip.tsx": 1,
|
||||||
|
"pages/tripPlanner/PlanMapStage.tsx": 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": 1195,
|
"src/components/Map/RouteCalculator.ts": 1076,
|
||||||
"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": 1193,
|
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||||
"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": 1131,
|
"src/pages/TripPlannerPage.tsx": 1128,
|
||||||
"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": 2087
|
"src/styles/studio.css": 2009
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": 17,
|
"src/components/Planner/PlaceInspector.tsx": 16,
|
||||||
"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,8 +82,6 @@
|
|||||||
"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,
|
||||||
@@ -98,7 +96,6 @@
|
|||||||
"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,
|
||||||
|
|||||||
@@ -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/SpreadView.tsx": 3,
|
"components/Studio/PhotoView.tsx": 2,
|
||||||
|
"components/Studio/SpreadView.tsx": 1,
|
||||||
"components/Studio/StudioElementsPanel.tsx": 8,
|
"components/Studio/StudioElementsPanel.tsx": 8,
|
||||||
"components/Studio/StudioInspector.tsx": 2,
|
|
||||||
"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,7 +132,6 @@
|
|||||||
"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,
|
||||||
@@ -252,7 +251,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": 27,
|
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 23,
|
||||||
"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,
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
"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/SpreadView.tsx": 2,
|
"components/Studio/PhotoView.tsx": 1,
|
||||||
|
"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,
|
||||||
|
|||||||
+4
-3
@@ -67,6 +67,7 @@ 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
|
||||||
@@ -410,10 +411,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. */}
|
{/* The phone has its own assistant screen inside the trip, and the home chat's outside it. */}
|
||||||
{!hideAuthedWidgets && isAuthenticated && !isPhone && (
|
{!hideAuthedWidgets && isAuthenticated && (
|
||||||
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
|
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
|
||||||
<Suspense fallback={null}><AssistantHost /></Suspense>
|
<Suspense fallback={null}>{isPhone ? <MAssistantHomeScreen /> : <AssistantHost />}</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)}
|
)}
|
||||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||||
|
|||||||
@@ -165,4 +165,46 @@ 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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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,
|
||||||
@@ -102,14 +103,16 @@ 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. Threads, turns,
|
* scope, which also reaches a journey's chat in TREK Studio and the home chat
|
||||||
* decisions, undo and stop exist under both; files, found places, proposals
|
* outside any one trip. Threads, turns, decisions, undo and stop exist under
|
||||||
* and sharing belong to a trip and take its id.
|
* all three; files, found places, proposals and sharing belong to a trip and
|
||||||
|
* 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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +146,13 @@ 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,
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
|||||||
|
|
||||||
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
|
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
|
||||||
|
|
||||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
const SETTINGS: AiAdminSettings = {
|
||||||
|
policy: 'instance_or_user',
|
||||||
|
dailyTurns: 50,
|
||||||
|
webSearch: true,
|
||||||
|
retention: { mode: 'days', days: 90 },
|
||||||
|
};
|
||||||
const LIVE: AiAdminConnectionResponse = {
|
const LIVE: AiAdminConnectionResponse = {
|
||||||
connection: {
|
connection: {
|
||||||
scope: 'instance',
|
scope: 'instance',
|
||||||
@@ -102,6 +107,17 @@ describe('the connection and the assistant', () => {
|
|||||||
expect(screen.getByText('Keep chats')).toBeInTheDocument();
|
expect(screen.getByText('Keep chats')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-AI-ADMIN-001b: the web search is on as it ships and is switched off on the assistant tab', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderDialog();
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'AI Assistant' }));
|
||||||
|
const toggle = screen.getByRole('button', { name: 'Web search' });
|
||||||
|
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByText(/through the search of the AI provider it talks to/)).toBeInTheDocument();
|
||||||
|
await user.click(toggle);
|
||||||
|
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, webSearch: false });
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
|
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderDialog();
|
renderDialog();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Check, Loader2, Sparkles } from 'lucide-react';
|
import { Check, Loader2, Sparkles } from 'lucide-react';
|
||||||
import React, { useId, useState } from 'react';
|
import React, { useId, useState } from 'react';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||||
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||||
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
|
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
|
||||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||||
@@ -136,7 +137,7 @@ function InstanceConnection({ A }: { A: AiAdmin }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
|
/** The assistant's own limits: turns a day while the instance pays, how long chats are kept, whether it searches the web. */
|
||||||
function AssistantLimits({ A }: { A: AiAdmin }) {
|
function AssistantLimits({ A }: { A: AiAdmin }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fieldId = useId();
|
const fieldId = useId();
|
||||||
@@ -170,6 +171,19 @@ function AssistantLimits({ A }: { A: AiAdmin }) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</EditorField>
|
</EditorField>
|
||||||
|
{A.settings && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-body font-medium text-content">{t('assistant.admin.webSearch')}</span>
|
||||||
|
<ToggleSwitch
|
||||||
|
on={A.settings.webSearch}
|
||||||
|
onToggle={() => A.setWebSearch(!A.settings?.webSearch)}
|
||||||
|
label={t('assistant.admin.webSearch')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SettingsHint>{t('assistant.admin.webSearchHint')}</SettingsHint>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ const wrapper = ({ children }: { children: ReactNode }) =>
|
|||||||
createElement(TranslationProvider, null, children)
|
createElement(TranslationProvider, null, children)
|
||||||
);
|
);
|
||||||
|
|
||||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
const SETTINGS: AiAdminSettings = {
|
||||||
|
policy: 'instance_or_user',
|
||||||
|
dailyTurns: 50,
|
||||||
|
webSearch: true,
|
||||||
|
retention: { mode: 'days', days: 90 },
|
||||||
|
};
|
||||||
const NONE: AiAdminConnectionResponse = { connection: null, prefill: null };
|
const NONE: AiAdminConnectionResponse = { connection: null, prefill: null };
|
||||||
const LIVE: AiAdminConnectionResponse = {
|
const LIVE: AiAdminConnectionResponse = {
|
||||||
connection: {
|
connection: {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function retentionOf(value: string): AiRetention {
|
|||||||
return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) };
|
return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Policy, daily limit and retention: each change is saved at once and taken back when the server refuses it. */
|
/** Policy, daily limit, retention and web search: each change is saved at once and taken back when the server refuses it. */
|
||||||
function useAdminSettings(onSaved: () => void) {
|
function useAdminSettings(onSaved: () => void) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -92,6 +92,7 @@ function useAdminSettings(onSaved: () => void) {
|
|||||||
{ value: FOREVER, label: t('assistant.admin.retentionForever') },
|
{ value: FOREVER, label: t('assistant.admin.retentionForever') },
|
||||||
],
|
],
|
||||||
setRetention: (value: string) => void save({ retention: retentionOf(value) }),
|
setRetention: (value: string) => void save({ retention: retentionOf(value) }),
|
||||||
|
setWebSearch: (webSearch: boolean) => void save({ webSearch }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Check, LoaderCircle, MapPin, Undo2 } from 'lucide-react';
|
import { Check, CheckCheck, ListChecks, LoaderCircle, MapPin, Undo2 } from 'lucide-react';
|
||||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
import { blockKeyOf } from '../../store/assistantMapModel';
|
import { blockKeyOf } from '../../store/assistantMapModel';
|
||||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
import { useTripStore } from '../../store/tripStore';
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import { DialogButton, NEUTRAL_TINT } from '../shared/DialogShell';
|
||||||
import { useToast } from '../shared/Toast';
|
import { useToast } from '../shared/Toast';
|
||||||
import { Tooltip } from '../shared/Tooltip';
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
||||||
@@ -13,6 +14,8 @@ import AssistantWeekOverview from './AssistantWeekOverview';
|
|||||||
import { type ChangeItem, undoLeftMs } from './assistantParts';
|
import { type ChangeItem, undoLeftMs } from './assistantParts';
|
||||||
import { useAssistantView } from './assistantViewContext';
|
import { useAssistantView } from './assistantViewContext';
|
||||||
import { ChangeRows } from './cards/ChangeCardSlots';
|
import { ChangeRows } from './cards/ChangeCardSlots';
|
||||||
|
import CardHeader from './cards/parts/CardHeader';
|
||||||
|
import { CardFoot } from './cards/parts/CardSection';
|
||||||
import { ICON_BUTTON } from './cards/parts/assistantTones';
|
import { ICON_BUTTON } from './cards/parts/assistantTones';
|
||||||
import { useChangeExclusions } from './cards/useChangeExclusions';
|
import { useChangeExclusions } from './cards/useChangeExclusions';
|
||||||
import { changePoints, type DayChange } from './dayAfterModel';
|
import { changePoints, type DayChange } from './dayAfterModel';
|
||||||
@@ -60,6 +63,9 @@ function undoableNow(item: ChangeItem, undone: Readonly<Record<string, true>>, n
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The band of a card whose changes are all decided: the green of a done booking. */
|
||||||
|
const DONE_TINT = 'color-mix(in srgb, var(--success) 9%, transparent)';
|
||||||
|
|
||||||
/** The waiting changes the user left on that touch the day plan. */
|
/** The waiting changes the user left on that touch the day plan. */
|
||||||
function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string>): DayChange[] {
|
function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string>): DayChange[] {
|
||||||
return items.flatMap((i) =>
|
return items.flatMap((i) =>
|
||||||
@@ -69,17 +75,18 @@ function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The card's head: what the card is, what can be done with it as a whole, and how many changes it holds. */
|
/** The change card's band: tinted green once everything in it is decided, what it holds and its actions. */
|
||||||
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
|
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="mb-1 flex min-h-7 items-center gap-2">
|
<CardHeader
|
||||||
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-content-secondary">
|
icon={open ? ListChecks : CheckCheck}
|
||||||
{t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
tone={open ? 'info' : 'success'}
|
||||||
</span>
|
tint={open ? NEUTRAL_TINT : DONE_TINT}
|
||||||
{actions}
|
title={t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
||||||
<span className="text-caption tabular-nums text-content-faint">{count}</span>
|
count={count}
|
||||||
</div>
|
actions={actions}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +231,7 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
|||||||
|
|
||||||
const open = pending.length > 0;
|
const open = pending.length > 0;
|
||||||
return (
|
return (
|
||||||
<section className="trek-popover-enter rounded-xl border border-edge-faint bg-surface-card px-3 pb-2 pt-2.5 shadow-card">
|
<section className="trek-popover-enter overflow-hidden rounded-[16px] border border-edge-faint bg-surface-card px-3.5 pb-3 pt-3 shadow-card">
|
||||||
<CardHead
|
<CardHead
|
||||||
open={open}
|
open={open}
|
||||||
count={items.length}
|
count={items.length}
|
||||||
@@ -257,25 +264,19 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
|||||||
<p className="sr-only" role="status">
|
<p className="sr-only" role="status">
|
||||||
{t('assistant.change.pending', { count: pending.length })}
|
{t('assistant.change.pending', { count: pending.length })}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-2 border-t border-edge-faint pt-2.5">
|
<CardFoot>
|
||||||
<button
|
<DialogButton disabled={isRunning} onClick={() => void submit(false)}>
|
||||||
type="button"
|
|
||||||
disabled={isRunning}
|
|
||||||
onClick={() => void submit(false)}
|
|
||||||
className="rounded-lg px-3 py-1.5 text-body text-content-secondary hover:bg-surface-hover hover:text-content disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('assistant.change.discard')}
|
{t('assistant.change.discard')}
|
||||||
</button>
|
</DialogButton>
|
||||||
<button
|
<DialogButton
|
||||||
type="button"
|
variant="primary"
|
||||||
disabled={isRunning || chosen === 0}
|
disabled={isRunning || chosen === 0}
|
||||||
onClick={() => void submit(true)}
|
onClick={() => void submit(true)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-body font-medium text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
icon={<Check size={14} aria-hidden="true" />}
|
||||||
>
|
>
|
||||||
<Check size={14} aria-hidden="true" />
|
|
||||||
{pending.length === 1 ? t('assistant.change.apply') : t('assistant.change.applyN', { count: chosen })}
|
{pending.length === 1 ? t('assistant.change.apply') : t('assistant.change.applyN', { count: chosen })}
|
||||||
</button>
|
</DialogButton>
|
||||||
</div>
|
</CardFoot>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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';
|
||||||
@@ -45,7 +46,11 @@ 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);
|
||||||
const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
|
// The home chat is about no one trip, so its placeholder names none.
|
||||||
|
const fallback =
|
||||||
|
tripId === HOME_CHAT_KEY
|
||||||
|
? t('assistant.home.placeholder')
|
||||||
|
: t('assistant.composer.placeholder', { trip: tripTitle });
|
||||||
const placeholder = useTypewriterPlaceholder(
|
const placeholder = useTypewriterPlaceholder(
|
||||||
suggestions.map((s) => s.prompt),
|
suggestions.map((s) => s.prompt),
|
||||||
fallback,
|
fallback,
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
|
import {
|
||||||
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
|
Ellipsis,
|
||||||
|
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';
|
||||||
@@ -12,7 +24,9 @@ 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';
|
||||||
@@ -29,6 +43,16 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,7 +63,14 @@ interface AssistantHeaderProps {
|
|||||||
* 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({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
|
export default function AssistantHeader({
|
||||||
|
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);
|
||||||
@@ -48,6 +79,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
|||||||
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);
|
||||||
@@ -64,6 +96,14 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
|||||||
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,
|
||||||
@@ -103,11 +143,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{thread.isRunning ? (
|
{thread.isRunning ? (
|
||||||
// While it works, the line over the title says what it does right now.
|
// While it works, the line over the title says what it does right now.
|
||||||
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption font-medium">
|
<p role="status" className="m-0 flex min-w-0 items-center text-caption font-medium">
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="trek-assistant-pulse h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
|
|
||||||
/>
|
|
||||||
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
|
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -126,6 +162,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
|||||||
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
|
||||||
@@ -159,10 +196,12 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
|||||||
</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" />
|
||||||
@@ -190,6 +229,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
|||||||
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">
|
||||||
@@ -206,7 +246,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
|||||||
)}
|
)}
|
||||||
</PopoverListPanel>
|
</PopoverListPanel>
|
||||||
|
|
||||||
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
<ContextMenu menu={menu.menu} onClose={closeMenu} />
|
||||||
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
|
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={confirming}
|
isOpen={confirming}
|
||||||
@@ -222,3 +262,49 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
|||||||
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
|||||||
import { useLocation, useMatch, useNavigate } from 'react-router';
|
import { useLocation, useMatch, useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { rememberListsSubTab } from '../../hooks/useListsSubTab';
|
import { rememberListsSubTab } from '../../hooks/useListsSubTab';
|
||||||
|
import { useAssistantStore } from '../../store/assistantStore';
|
||||||
import { useTripStore } from '../../store/tripStore';
|
import { useTripStore } from '../../store/tripStore';
|
||||||
import AssistantWindow from './AssistantWindow';
|
import AssistantWindow from './AssistantWindow';
|
||||||
import { plannerSearchOf } from './assistantShowOnMap';
|
import { plannerSearchOf } from './assistantShowOnMap';
|
||||||
@@ -16,13 +17,14 @@ 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; what a
|
* keeps going from page to page. Inside a trip it is about that trip, outside
|
||||||
|
* one it is the home chat about all of them (AssistantWindow); what a
|
||||||
* 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
|
||||||
* own tab remembered first), and another page is simply navigated to. A day
|
* own tab remembered first), and another page is simply navigated to. A day
|
||||||
* picked in the chat (its weather) selects that day the same way, and "On
|
* picked in the chat (its weather) selects that day the same way, and "On
|
||||||
* map" on a card brings the planner's map back first.
|
* map" on a card brings the planner's map back first from a tab without one.
|
||||||
*/
|
*/
|
||||||
export default function AssistantHost() {
|
export default function AssistantHost() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -54,8 +56,12 @@ export default function AssistantHost() {
|
|||||||
[navigate, openPlanner, tripId]
|
[navigate, openPlanner, tripId]
|
||||||
);
|
);
|
||||||
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
|
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
|
||||||
// "On map" from another tab of the planner (Tours, Costs, ...) first brings the map back.
|
// "On map" from a tab without a map (Costs, Lists, ...) first brings the plan's map back; the Tours tab
|
||||||
const revealMap = useCallback(() => openPlanner('?tab=plan'), [openPlanner]);
|
// shows the route on its own map. A map on screen tells the assistant its view, which is how it is known.
|
||||||
|
const revealMap = useCallback(() => {
|
||||||
|
if (useAssistantStore.getState().mapView?.tripId === tripId) return;
|
||||||
|
openPlanner('?tab=plan');
|
||||||
|
}, [openPlanner, tripId]);
|
||||||
|
|
||||||
return <AssistantWindow trip={trip} onOpen={open} onFocusDay={focusDay} onRevealMap={revealMap} />;
|
return <AssistantWindow trip={trip} onOpen={open} onFocusDay={focusDay} onRevealMap={revealMap} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,14 @@ 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 trip picker. Nothing shows until the
|
* window; outside a trip it opens on the home chat. 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. Only a
|
// An answer still being written in a closed window: the floating button breathes until it is opened. A
|
||||||
// trip's chat counts: an answer still running in the Studio is not one this window opens on.
|
// trip's chat and the home chat count: 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 === 'trip')
|
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind !== 'journey')
|
||||||
: false
|
: false
|
||||||
);
|
);
|
||||||
const keys = useAssistantShortcutKeys();
|
const keys = useAssistantShortcutKeys();
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import AssistantFollowUps from './AssistantFollowUps';
|
|||||||
import AssistantMarkdown from './AssistantMarkdown';
|
import AssistantMarkdown from './AssistantMarkdown';
|
||||||
import AssistantMentionText from './AssistantMentionText';
|
import AssistantMentionText from './AssistantMentionText';
|
||||||
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||||
|
import AssistantRemembered from './AssistantRemembered';
|
||||||
import AssistantShareDialog from './AssistantShareDialog';
|
import AssistantShareDialog from './AssistantShareDialog';
|
||||||
|
import AssistantSources from './AssistantSources';
|
||||||
import AssistantToolSteps from './AssistantToolSteps';
|
import AssistantToolSteps from './AssistantToolSteps';
|
||||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||||
import { messageBlocks } from './assistantParts';
|
import { messageBlocks } from './assistantParts';
|
||||||
@@ -129,6 +131,10 @@ function Answer({ message }: { message: StoredMessage }) {
|
|||||||
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
||||||
}
|
}
|
||||||
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
||||||
|
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
|
||||||
|
if (block.kind === 'remembered') {
|
||||||
|
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
|
||||||
|
}
|
||||||
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
|
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
|
||||||
return <AssistantChangeCard key={block.key} items={block.items} />;
|
return <AssistantChangeCard key={block.key} items={block.items} />;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { AssistantFoundPlace } from '@trek/shared';
|
import type { AssistantFoundPlace } from '@trek/shared';
|
||||||
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
|
import { Check, MapPin, Plus, Route, 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';
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons';
|
import { getCategoryIcon } from '../shared/categoryIcons';
|
||||||
import { PopoverListPanel } from '../shared/dialogParts';
|
|
||||||
import { Tooltip } from '../shared/Tooltip';
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
import { usePopoverList } from '../shared/usePopoverList';
|
|
||||||
import { useAssistantView } from './assistantViewContext';
|
import { useAssistantView } from './assistantViewContext';
|
||||||
import type { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
|
import type { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
|
||||||
import { useFoundPlacePhoto } from './useFoundPlacePhoto';
|
import { useFoundPlacePhoto } from './useFoundPlacePhoto';
|
||||||
@@ -16,110 +14,32 @@ type Strip = ReturnType<typeof useAssistantPlaceStrip>;
|
|||||||
const ACTION =
|
const ACTION =
|
||||||
'inline-flex items-center gap-1 rounded-lg bg-surface-secondary text-caption font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50 disabled:hover:bg-surface-secondary';
|
'inline-flex items-center gap-1 rounded-lg bg-surface-secondary text-caption font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50 disabled:hover:bg-surface-secondary';
|
||||||
|
|
||||||
/** "+ Day 3" for the day in focus, a list of the trip's days beside it; "Pick a day" when no day is in focus. */
|
/** "Add to trip": opens the planner's place form filled in with the place, its details loading beside it. */
|
||||||
function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { days, focusDay, disabled, blockedKey, adding } = strip;
|
|
||||||
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
|
|
||||||
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
|
|
||||||
});
|
|
||||||
const { open, toggle, close, triggerRef, listId } = list;
|
|
||||||
const height = tall ? 'h-10' : 'h-7';
|
|
||||||
const busy = adding === place.ref;
|
|
||||||
|
|
||||||
const pick = (dayId: number) => {
|
|
||||||
close();
|
|
||||||
void strip.suggest(place, dayId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttons = (
|
|
||||||
<span className="inline-flex min-w-0 gap-px">
|
|
||||||
{focusDay ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => void strip.suggest(place, focusDay.id)}
|
|
||||||
aria-label={t('assistant.place.addTo', { day: focusDay.label })}
|
|
||||||
className={`${ACTION} ${height} rounded-e-none px-2`}
|
|
||||||
>
|
|
||||||
{busy ? (
|
|
||||||
<LoaderCircle size={12} aria-hidden="true" className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plus size={12} aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={toggle}
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={open ? listId : undefined}
|
|
||||||
aria-label={t('assistant.place.pickDay')}
|
|
||||||
className={`${ACTION} ${height} rounded-s-none px-1.5`}
|
|
||||||
>
|
|
||||||
<ChevronDown size={12} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={toggle}
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={open ? listId : undefined}
|
|
||||||
className={`${ACTION} ${height} px-2`}
|
|
||||||
>
|
|
||||||
<Plus size={12} aria-hidden="true" />
|
|
||||||
<span className="truncate">{t('assistant.place.pickDay')}</span>
|
|
||||||
<ChevronDown size={12} aria-hidden="true" className="text-content-faint" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<button type="button" onClick={() => strip.open(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
|
||||||
{blockedKey ? (
|
<Plus size={12} aria-hidden="true" />
|
||||||
<Tooltip label={t(blockedKey)} placement="top">
|
<span className="truncate">{t('assistant.place.add')}</span>
|
||||||
{/* A disabled button gets no pointer events; the wrapper carries the reason. */}
|
</button>
|
||||||
<span className="inline-flex min-w-0">{buttons}</span>
|
);
|
||||||
</Tooltip>
|
}
|
||||||
) : (
|
|
||||||
buttons
|
/** "+ 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 }) {
|
||||||
{/* A grid of day tiles, not a list: a month-long trip still fits in a few rows. */}
|
const { t } = useTranslation();
|
||||||
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="w-[15.5rem] p-2">
|
if (!strip.tour) return null;
|
||||||
<div className="grid grid-cols-7 gap-1">
|
return (
|
||||||
{days.map((day) => {
|
<Tooltip label={t('assistant.place.addToTourHint', { name: strip.tour.name })} placement="top">
|
||||||
const current = day.id === focusDay?.id;
|
<button
|
||||||
return (
|
type="button"
|
||||||
<Tooltip key={day.id} label={day.label} placement="top">
|
onClick={() => strip.addToTour(place)}
|
||||||
<button
|
className={`${ACTION} ${tall ? 'h-10' : 'h-7'} min-w-0 px-2`}
|
||||||
type="button"
|
>
|
||||||
onClick={() => pick(day.id)}
|
<Route size={12} aria-hidden="true" className="flex-shrink-0" />
|
||||||
aria-label={day.label}
|
<span className="truncate">{t('assistant.place.addToTour')}</span>
|
||||||
aria-pressed={current}
|
</button>
|
||||||
className={`flex h-10 flex-col items-center justify-center rounded-lg leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
|
</Tooltip>
|
||||||
current ? 'bg-accent text-accent-text' : 'text-content hover:bg-surface-hover'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-body font-semibold tabular-nums">{day.n}</span>
|
|
||||||
{day.weekday && (
|
|
||||||
<span className={`mt-0.5 text-caption ${current ? 'text-accent-text' : 'text-content-faint'}`}>
|
|
||||||
{day.weekday}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</PopoverListPanel>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +139,7 @@ function PlaceAction({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
|
return strip.canAdd ? <AddButton place={place} strip={strip} tall={tall} /> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -227,9 +147,10 @@ function PlaceAction({
|
|||||||
* with the category it most likely belongs in on it (or that category's icon
|
* with the category it most likely belongs in on it (or that category's icon
|
||||||
* on its colour), its name, address and rating, whether it is open now while
|
* on its colour), its name, address and rating, whether it is open now while
|
||||||
* the trip runs, how far it is from the last stop of the day in focus, and one
|
* the trip runs, how far it is from the last stop of the day in focus, and one
|
||||||
* way on: "+ Day 3" for a new place, "On map" for one the trip has. A click on
|
* way on: "Add to trip" for a new place, "On map" for one the trip has. A click
|
||||||
* the card shows it on the map, and resting the pointer on it pins it there
|
* on a new place opens the place form filled in with it (one the trip has, or
|
||||||
* for a moment.
|
* any where nothing can be added, shows on the map), and resting the pointer on
|
||||||
|
* it pins it there for a moment.
|
||||||
*/
|
*/
|
||||||
export default function AssistantPlaceCard({
|
export default function AssistantPlaceCard({
|
||||||
place,
|
place,
|
||||||
@@ -259,7 +180,7 @@ export default function AssistantPlaceCard({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => strip.show(place)}
|
onClick={() => (place.inTripPlaceId === null && strip.canAdd ? strip.open(place) : strip.show(place))}
|
||||||
onFocus={() => strip.hover(place)}
|
onFocus={() => strip.hover(place)}
|
||||||
onBlur={() => strip.hover(null)}
|
onBlur={() => strip.hover(null)}
|
||||||
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
|
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
|
||||||
@@ -282,8 +203,9 @@ export default function AssistantPlaceCard({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
|
<div className="mt-auto flex min-w-0 gap-1.5 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
* FE-AI-PLACES-001 to FE-AI-PLACES-010
|
* FE-AI-PLACES-001 to FE-AI-PLACES-010
|
||||||
*
|
*
|
||||||
* The strip of places a search found: what a card says, that its picture only
|
* The strip of places a search found: what a card says, that its picture only
|
||||||
* ever comes from TREK's photo proxy, "+ Day N" for the day in focus or a
|
* ever comes from TREK's photo proxy, "Add to trip" opening the place form on
|
||||||
* picked one, why it waits while a change is undecided, the map's part, and
|
* the day in focus, the map's part, and no "Add to trip" in a journey's chat.
|
||||||
* no "+ Day" in a journey's chat.
|
|
||||||
*/
|
*/
|
||||||
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
|
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
@@ -22,6 +21,7 @@ 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';
|
||||||
@@ -67,7 +67,6 @@ const answer = (parts: AssistantMessage['parts'] = []): AssistantMessage => ({
|
|||||||
parts,
|
parts,
|
||||||
});
|
});
|
||||||
|
|
||||||
const addFoundPlace = vi.fn(async () => {});
|
|
||||||
const requestCamera = vi.fn();
|
const requestCamera = vi.fn();
|
||||||
const setPreviewPin = vi.fn();
|
const setPreviewPin = vi.fn();
|
||||||
const onRevealMap = vi.fn();
|
const onRevealMap = vi.fn();
|
||||||
@@ -83,7 +82,6 @@ function seed({ focusDayId = 31, messages = [] as AssistantMessage[] } = {}) {
|
|||||||
categories: [buildCategory({ id: 5, name: 'Restaurant', icon: 'Utensils' })],
|
categories: [buildCategory({ id: 5, name: 'Restaurant', icon: 'Utensils' })],
|
||||||
});
|
});
|
||||||
useAssistantStore.setState({
|
useAssistantStore.setState({
|
||||||
addFoundPlace,
|
|
||||||
requestCamera,
|
requestCamera,
|
||||||
setPreviewPin,
|
setPreviewPin,
|
||||||
byTrip: {
|
byTrip: {
|
||||||
@@ -108,6 +106,7 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,34 +164,30 @@ describe('AssistantPlaceStrip', () => {
|
|||||||
expect(fetchPhoto).toHaveBeenCalledWith('good', 'good', 41.889, 12.47, 'Trattoria s1:0', expect.any(Function));
|
expect(fetchPhoto).toHaveBeenCalledWith('good', 'good', 41.889, 12.47, 'Trattoria s1:0', expect.any(Function));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-AI-PLACES-004: "+ Day 1" suggests the place for the day in focus', () => {
|
it('FE-AI-PLACES-004: "Add to trip" opens the place form with the place, on the day in focus', () => {
|
||||||
seed();
|
seed();
|
||||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||||
fireEvent.click(screen.getByRole('button', { name: /^Add to/ }));
|
fireEvent.click(screen.getByRole('button', { name: 'Add to trip' }));
|
||||||
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 31);
|
expect(onRevealMap).toHaveBeenCalled();
|
||||||
|
expect(useAssistantStore.getState().placeDraft).toMatchObject({ tripId: TRIP, dayId: 31, place: { ref: 's1:0' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-AI-PLACES-005: another day is picked from the list, and without a focus the list is the only way', () => {
|
it('FE-AI-PLACES-005: a click on a new place opens the form too, without a day when none is in focus', () => {
|
||||||
seed({ focusDayId: 0 });
|
seed({ focusDayId: 0 });
|
||||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Pick a day/ }));
|
fireEvent.click(screen.getByText('Trattoria s1:0'));
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Nov 3/ }));
|
expect(useAssistantStore.getState().placeDraft).toMatchObject({ dayId: null, place: { ref: 's1:0' } });
|
||||||
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 32);
|
expect(requestCamera).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-AI-PLACES-006: waits while a change is undecided, and offline', () => {
|
it('FE-AI-PLACES-006: opening the form waits for nothing: an undecided change and offline leave it on', () => {
|
||||||
const waiting = answer([
|
const waiting = answer([
|
||||||
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } },
|
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } },
|
||||||
]);
|
]);
|
||||||
seed({ messages: [waiting] });
|
seed({ messages: [waiting] });
|
||||||
const { unmount } = renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
|
||||||
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
seed();
|
|
||||||
setForcedOffline(true);
|
setForcedOffline(true);
|
||||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||||
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
expect(screen.getByRole('button', { name: 'Add to trip' })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-AI-PLACES-007: a suggested place says so, and one the trip has goes to the map instead', () => {
|
it('FE-AI-PLACES-007: a suggested place says so, and one the trip has goes to the map instead', () => {
|
||||||
@@ -234,15 +229,33 @@ describe('AssistantPlaceStrip', () => {
|
|||||||
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
|
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-AI-PLACES-010: a journey chat lists the places but offers no "+ Day" while the trip is on screen', () => {
|
it('FE-AI-PLACES-010: a journey chat lists the places but offers no "Add to trip" while the trip is on screen', () => {
|
||||||
// The journey key never matches the planner's trip, so the trip check already hides "+ Day";
|
// The journey key never matches the planner's trip, so the trip check already hides "Add to trip";
|
||||||
// the scope check in the hook is defence in depth for the day trips and journeys share keys.
|
// the scope check in the hook is defence in depth for the day trips and journeys share keys.
|
||||||
seed();
|
seed();
|
||||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 }, answer(), {
|
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 }, answer(), {
|
||||||
tripId: assistantChatKey({ kind: 'journey', journeyId: TRIP }),
|
tripId: assistantChatKey({ kind: 'journey', journeyId: TRIP }),
|
||||||
});
|
});
|
||||||
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
|
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: /^Add to/ })).toBeNull();
|
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
|
||||||
expect(screen.queryByRole('button', { name: /Pick a day/ })).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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-REMEMBER-001 to FE-AI-REMEMBER-002
|
||||||
|
*
|
||||||
|
* A note the assistant kept on its own: a quiet line naming it, the trip
|
||||||
|
* notes read again while the answer is written, and the notes a tap away.
|
||||||
|
*/
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||||
|
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import AssistantRemembered from './AssistantRemembered';
|
||||||
|
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||||
|
|
||||||
|
const SETTINGS = useSettingsStore.getState();
|
||||||
|
|
||||||
|
function renderLine(live: boolean) {
|
||||||
|
const view: AssistantView = { tripId: 4, variant: 'window', density: 'compact' };
|
||||||
|
return render(
|
||||||
|
<AssistantViewContext.Provider value={view}>
|
||||||
|
<AssistantRemembered note="We eat vegetarian" live={live} />
|
||||||
|
</AssistantViewContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useSettingsStore.setState(SETTINGS, true);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AssistantRemembered', () => {
|
||||||
|
it('FE-AI-REMEMBER-001: names the note, and reads the notes again only while the answer is written', () => {
|
||||||
|
const loadSettings = vi.fn(async () => {});
|
||||||
|
useSettingsStore.setState({ loadSettings });
|
||||||
|
const { unmount } = renderLine(false);
|
||||||
|
expect(screen.getByText('We eat vegetarian')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Noted for this trip:')).toBeInTheDocument();
|
||||||
|
expect(loadSettings).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
renderLine(true);
|
||||||
|
expect(loadSettings).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-REMEMBER-002: the pencil opens the trip notes', () => {
|
||||||
|
useSettingsStore.setState({ loadSettings: vi.fn(async () => {}) });
|
||||||
|
renderLine(false);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edit trip notes' }));
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { BookmarkCheck, Pencil } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
import AssistantMemoryDialog from './AssistantMemoryDialog';
|
||||||
|
import { useAssistantScope } from './assistantViewContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A note the assistant kept in the trip notes on its own, as a quiet line in
|
||||||
|
* its answer, with the notes a tap away to change or drop it. Kept while the
|
||||||
|
* answer is written, the notes on this device are read again, so the dialog
|
||||||
|
* never saves over the note with what it held before.
|
||||||
|
*/
|
||||||
|
export default function AssistantRemembered({ note, live }: { note: string; live: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const scope = useAssistantScope();
|
||||||
|
const loadSettings = useSettingsStore((s) => s.loadSettings);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (live) void loadSettings();
|
||||||
|
}, [live, loadSettings]);
|
||||||
|
|
||||||
|
const tripId = scope.kind === 'trip' ? scope.tripId : null;
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-surface-secondary px-2.5 py-1.5 text-caption">
|
||||||
|
<BookmarkCheck size={13} strokeWidth={2.2} aria-hidden="true" className="flex-none text-success" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="font-semibold text-content-secondary">{t('assistant.memory.kept')}</span>{' '}
|
||||||
|
<bdi className="text-content-muted">{note}</bdi>
|
||||||
|
</span>
|
||||||
|
{tripId !== null && (
|
||||||
|
<Tooltip label={t('assistant.memory.edit')} placement="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
aria-label={t('assistant.memory.edit')}
|
||||||
|
className="grid h-6 w-6 flex-none place-items-center rounded-md text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||||
|
>
|
||||||
|
<Pencil size={12} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{editing && tripId !== null && <AssistantMemoryDialog tripId={tripId} onClose={() => setEditing(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-SOURCES-001 to FE-AI-SOURCES-002
|
||||||
|
*
|
||||||
|
* The pages an answer cites from the provider's web search: a chip per page
|
||||||
|
* with its site and title, opening in a new tab without telling the page who
|
||||||
|
* sent the reader, and the block they form in an answer.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { render, screen } from '../../../tests/helpers/render';
|
||||||
|
import AssistantSources from './AssistantSources';
|
||||||
|
import { messageBlocks } from './assistantParts';
|
||||||
|
|
||||||
|
describe('AssistantSources', () => {
|
||||||
|
it('FE-AI-SOURCES-001: names each page by its site and title, and opens it in a new tab', () => {
|
||||||
|
render(
|
||||||
|
<AssistantSources
|
||||||
|
sources={[
|
||||||
|
{ url: 'https://www.rostock.de/hafen', title: 'Stadthafen Rostock' },
|
||||||
|
{ url: 'https://example.org/a', title: 'https://example.org/a' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('navigation', { name: 'Sources' })).toBeInTheDocument();
|
||||||
|
const link = screen.getByRole('link', { name: /rostock\.de/ });
|
||||||
|
expect(link).toHaveAttribute('href', 'https://www.rostock.de/hafen');
|
||||||
|
expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
expect(link.getAttribute('rel')).toContain('noreferrer');
|
||||||
|
expect(screen.getByText('Stadthafen Rostock')).toBeInTheDocument();
|
||||||
|
// A title that is only the address shows the site alone.
|
||||||
|
expect(screen.getByRole('link', { name: 'example.org' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-SOURCES-002: an answer keeps its sources as a block of their own after its text', () => {
|
||||||
|
const blocks = messageBlocks(
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'complete',
|
||||||
|
createdAt: '2026-10-23T10:00:00.000Z',
|
||||||
|
parts: [
|
||||||
|
{ type: 'tool-call', toolCallId: 'web-1', toolName: 'web_search', args: { query: 'x' }, result: 'searched' },
|
||||||
|
{ type: 'text', text: 'Eat at the harbour.' },
|
||||||
|
{ type: 'data-trek-sources', data: { sources: [{ url: 'https://a.example', title: 'A' }] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(blocks.map((block) => block.kind)).toEqual(['steps', 'text', 'sources']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { AssistantSources as Sources } from '@trek/shared';
|
||||||
|
import { Globe } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
|
||||||
|
/** A page's host without "www.", as the chip names it; the whole address where it does not read as one. */
|
||||||
|
function hostOf(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pages an answer drew on from the provider's web search, under its text:
|
||||||
|
* a row of quiet chips with each page's site and title, opening in a new tab.
|
||||||
|
* No page's icon is loaded, so showing the sources asks no site for anything.
|
||||||
|
*/
|
||||||
|
export default function AssistantSources({ sources }: { sources: Sources['sources'] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<nav aria-label={t('assistant.sources.title')} className="flex flex-col gap-1.5">
|
||||||
|
<span className="font-geist text-caption font-bold uppercase tracking-[.08em] text-content-faint">
|
||||||
|
{t('assistant.sources.title')}
|
||||||
|
</span>
|
||||||
|
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
|
||||||
|
{sources.map((source) => (
|
||||||
|
<li key={source.url} className="min-w-0 max-w-full">
|
||||||
|
<a
|
||||||
|
href={source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
title={source.title}
|
||||||
|
className="flex min-w-0 max-w-[16rem] items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2.5 py-1 text-caption text-content-secondary no-underline transition-colors hover:bg-surface-hover hover:text-content"
|
||||||
|
>
|
||||||
|
<Globe size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
|
||||||
|
<span className="flex-none font-medium">{hostOf(source.url)}</span>
|
||||||
|
{source.title && source.title !== source.url && (
|
||||||
|
<bdi className="min-w-0 truncate text-content-muted">{source.title}</bdi>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -177,7 +177,7 @@ describe('AssistantSurface', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: 'text', text: 'Rain on Monday, so the market is a good pick.' },
|
{ type: 'text', text: 'Rain on Monday, so Nishiki Market is a good pick.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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.
|
||||||
@@ -41,8 +42,9 @@ export default function AssistantSurface({
|
|||||||
onFocusDay,
|
onFocusDay,
|
||||||
onRevealMap,
|
onRevealMap,
|
||||||
bookVersion,
|
bookVersion,
|
||||||
|
onOpenTripChat,
|
||||||
}),
|
}),
|
||||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion]
|
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<AssistantRuntimeProvider runtime={runtime}>
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
@@ -6,12 +7,19 @@ import { Spinner } from '../shared/Spinner';
|
|||||||
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker';
|
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outside a trip the assistant first asks which one: the chat, its history
|
* The user's trips to go on with in one trip's own chat, from the home chat's
|
||||||
* and its changes all belong to one trip. The running and the next trip lead.
|
* head: that chat, its history and its changes belong to the trip. The
|
||||||
|
* running and the next trip lead. The list opens before the trips are there,
|
||||||
|
* so the focus goes onto the first one once they come.
|
||||||
*/
|
*/
|
||||||
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 (
|
||||||
@@ -31,9 +39,10 @@ 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) => (
|
{trips.map((trip, i) => (
|
||||||
<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,20 +28,15 @@ 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 Studio's chat is about a book, not a plan; the home chat about all of the user's travels.
|
||||||
const book = useAssistantScope().kind === 'journey';
|
const scope = useAssistantScope().kind;
|
||||||
|
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
|
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} />
|
||||||
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')}
|
||||||
@@ -53,6 +48,12 @@ 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,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* FE-AI-WIN-001 to FE-AI-WIN-012
|
* FE-AI-WIN-001 to FE-AI-WIN-016
|
||||||
*
|
*
|
||||||
* 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 trip picker outside a trip, closing that keeps the chat, and
|
* opens from, the home chat outside a trip with its way into one trip's chat
|
||||||
|
* 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
|
||||||
@@ -36,12 +37,14 @@ 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>
|
||||||
@@ -66,6 +69,11 @@ 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>
|
||||||
),
|
),
|
||||||
@@ -136,12 +144,36 @@ describe('AssistantWindow', () => {
|
|||||||
expect(container).toBeEmptyDOMElement();
|
expect(container).toBeEmptyDOMElement();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-AI-WIN-004: outside a trip it asks which trip first, then chats there', async () => {
|
it('FE-AI-WIN-004: outside a trip it opens on the home chat, whose head opens one trip chat and goes back', async () => {
|
||||||
seed({ open: true, minimized: false });
|
seed({ open: true, minimized: false });
|
||||||
render(<AssistantWindow trip={null} />);
|
render(<AssistantWindow trip={null} />);
|
||||||
expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
|
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument();
|
||||||
|
expect(resume).toHaveBeenCalledWith(0);
|
||||||
|
// The home chat has no trip notes.
|
||||||
|
expect(screen.queryByRole('button', { name: 'Trip notes' })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Chat about one trip' }));
|
||||||
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', () => {
|
||||||
@@ -206,6 +238,34 @@ 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();
|
||||||
@@ -238,6 +298,10 @@ 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,20 +1,21 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -30,17 +31,18 @@ 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface WindowTrip {
|
type WindowTrip = AssistantChatTrip;
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssistantWindowProps {
|
interface AssistantWindowProps {
|
||||||
/** The trip on screen; null outside a trip, where the window asks which one first. */
|
/** The trip on screen; null outside a trip, where the window opens on the home chat. */
|
||||||
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;
|
||||||
@@ -70,12 +72,16 @@ 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);
|
||||||
@@ -150,6 +156,7 @@ 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)}
|
||||||
@@ -162,6 +169,7 @@ function ChatWindow({
|
|||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onFocusDay={onFocusDay}
|
onFocusDay={onFocusDay}
|
||||||
onRevealMap={onRevealMap}
|
onRevealMap={onRevealMap}
|
||||||
|
onOpenTripChat={onOpenTripChat}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -177,19 +185,21 @@ 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 asks again.
|
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
|
||||||
if (open !== wasOpen) {
|
if (open !== wasOpen) {
|
||||||
setWasOpen(open);
|
setWasOpen(open);
|
||||||
if (!open) setPicked(null);
|
if (!open) setPicked(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = trip ?? picked;
|
const current = trip ?? picked ?? home.chat;
|
||||||
|
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) => {
|
||||||
@@ -198,6 +208,14 @@ 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) => {
|
||||||
@@ -209,52 +227,27 @@ 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={onScreen ? onOpen && openOnScreen : openPicked}
|
onOpen={openTarget}
|
||||||
// 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}
|
||||||
/>
|
/>
|
||||||
{minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
|
{/* Hidden while an answer may still come in: the round button brings it back, on any page. */}
|
||||||
|
{minimized && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} focusOnMount />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -168,20 +168,6 @@
|
|||||||
animation: trek-assistant-busy 1.8s ease-in-out infinite;
|
animation: trek-assistant-busy 1.8s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The dot beside what the assistant does right now: a soft beat, a halo going out. */
|
|
||||||
@keyframes trek-assistant-pulse {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
|
|
||||||
}
|
|
||||||
70%,
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 6px transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.trek-assistant-pulse {
|
|
||||||
animation: trek-assistant-pulse 1.4s ease-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.trek-assistant-pop,
|
.trek-assistant-pop,
|
||||||
.trek-assistant-enter {
|
.trek-assistant-enter {
|
||||||
@@ -190,8 +176,7 @@
|
|||||||
.trek-assistant-settle {
|
.trek-assistant-settle {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
.trek-assistant-busy,
|
.trek-assistant-busy {
|
||||||
.trek-assistant-pulse {
|
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
.trek-assistant-shimmer {
|
.trek-assistant-shimmer {
|
||||||
@@ -211,8 +196,7 @@
|
|||||||
[data-reduce-motion] .trek-assistant-settle {
|
[data-reduce-motion] .trek-assistant-settle {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
[data-reduce-motion] .trek-assistant-busy,
|
[data-reduce-motion] .trek-assistant-busy {
|
||||||
[data-reduce-motion] .trek-assistant-pulse {
|
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
[data-reduce-motion] .trek-assistant-shimmer {
|
[data-reduce-motion] .trek-assistant-shimmer {
|
||||||
|
|||||||
@@ -111,6 +111,14 @@ describe('the questions an answer offers', () => {
|
|||||||
|
|
||||||
it('FE-AI-FOLLOW-007: an answer that asks the user something waits for their answer, not for "tell me more"', () => {
|
it('FE-AI-FOLLOW-007: an answer that asks the user something waits for their answer, not for "tell me more"', () => {
|
||||||
expect(followUpsFor(of([{ type: 'text', text: 'Should I plan it anyway?' }]), true)).toEqual([]);
|
expect(followUpsFor(of([{ type: 'text', text: 'Should I plan it anyway?' }]), true)).toEqual([]);
|
||||||
|
// Not the found places' chips either, but the model's own answers to its question.
|
||||||
|
const asked = of([
|
||||||
|
{ type: 'data-trek-places', data: { toolCallId: 's', places: [], more: 0 } },
|
||||||
|
{ type: 'text', text: 'Shall I plan it there?' },
|
||||||
|
]);
|
||||||
|
expect(followUpsFor(asked, true)).toEqual([]);
|
||||||
|
const offered = of([...asked.parts, { type: 'data-trek-follow-ups', data: { questions: ['Yes, plan it'] } }]);
|
||||||
|
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['Yes, plan it']);
|
||||||
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
|
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,11 +126,12 @@ export function followUpsFor(message: AssistantMessage, canEdit: boolean): reado
|
|||||||
if (fromModel.length > 0) return fromModel;
|
if (fromModel.length > 0) return fromModel;
|
||||||
const fromCards = cardFollowUps(message, canEdit);
|
const fromCards = cardFollowUps(message, canEdit);
|
||||||
if (fromCards.length > 0) return fromCards;
|
if (fromCards.length > 0) return fromCards;
|
||||||
|
// An answer that asks the user something waits for their answer: only the model's own offer fits it.
|
||||||
|
if (endsWithQuestion(message)) return [];
|
||||||
const fromChanges = changeFollowUps(message, canEdit);
|
const fromChanges = changeFollowUps(message, canEdit);
|
||||||
if (fromChanges !== null) return fromChanges.length > 0 ? fromChanges : GENERAL;
|
if (fromChanges !== null) return fromChanges.length > 0 ? fromChanges : GENERAL;
|
||||||
const types = new Set(message.parts.map((part) => part.type));
|
const types = new Set(message.parts.map((part) => part.type));
|
||||||
if (types.has('data-trek-places')) return PLACES;
|
if (types.has('data-trek-places')) return PLACES;
|
||||||
if (types.has('data-trek-weather')) return WEATHER;
|
if (types.has('data-trek-weather')) return WEATHER;
|
||||||
// An answer that asks the user something waits for their answer, not for "tell me more".
|
return types.has('text') ? GENERAL : [];
|
||||||
return types.has('text') && !endsWithQuestion(message) ? GENERAL : [];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ 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',
|
||||||
@@ -224,6 +225,17 @@ 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
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ describe('messageBlocks: found places and weather', () => {
|
|||||||
read('s2', []),
|
read('s2', []),
|
||||||
places('s2', [found('s2:0', { name: 'Place s1:0' }), found('s2:1', { googlePlaceId: 'ChIJa', lat: 40 })], 3),
|
places('s2', [found('s2:0', { name: 'Place s1:0' }), found('s2:1', { googlePlaceId: 'ChIJa', lat: 40 })], 3),
|
||||||
places('s3', [found('s3:0', { lat: 40 })]),
|
places('s3', [found('s3:0', { lat: 40 })]),
|
||||||
{ type: 'text', text: 'Three good ones.' },
|
{ type: 'text', text: 'Three good ones, Place s3:0 first.' },
|
||||||
]),
|
]),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
@@ -208,10 +208,66 @@ describe('messageBlocks: found places and weather', () => {
|
|||||||
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
|
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
|
||||||
// The same name at the same spot, or the same Google id, shows once.
|
// The same name at the same spot, or the same Google id, shows once.
|
||||||
expect(blocks[1]).toMatchObject({
|
expect(blocks[1]).toMatchObject({
|
||||||
found: { toolCallId: 's3', more: 5, places: [{ ref: 's1:0' }, { ref: 's1:1' }, { ref: 's3:0' }] },
|
found: { toolCallId: 's3', more: 5, places: [{ ref: 's3:0' }, { ref: 's1:0' }, { ref: 's1:1' }] },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PARTS-008d: an answer that planned a tour shows no found places, even one whose planning failed', () => {
|
||||||
|
const blocks = messageBlocks(
|
||||||
|
message([
|
||||||
|
places('s1', [found('s1:0', { name: 'Treseburg' })]),
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: 'h1',
|
||||||
|
toolName: 'propose_hike',
|
||||||
|
args: {},
|
||||||
|
result: 'Router down',
|
||||||
|
isError: true,
|
||||||
|
},
|
||||||
|
{ type: 'text', text: 'Treseburg is the turning point.' },
|
||||||
|
]),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(blocks.map((b) => b.kind)).not.toContain('places');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PARTS-008c: a note kept in the trip notes is a line of its own, a refused or running one a step', () => {
|
||||||
|
const kept = (id: string, extra: Record<string, unknown>): AssistantMessagePart =>
|
||||||
|
({
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: id,
|
||||||
|
toolName: 'remember_for_trip',
|
||||||
|
args: { note: ' Vegetarian ' },
|
||||||
|
...extra,
|
||||||
|
}) as AssistantMessagePart;
|
||||||
|
const blocks = messageBlocks(
|
||||||
|
message([
|
||||||
|
kept('m1', { result: { notes: 'Vegetarian' } }),
|
||||||
|
kept('m2', { result: 'The trip notes are full.', isError: true }),
|
||||||
|
]),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(blocks).toMatchObject([
|
||||||
|
{ kind: 'remembered', note: 'Vegetarian' },
|
||||||
|
{ kind: 'steps', steps: [{ toolCallId: 'm2' }] },
|
||||||
|
]);
|
||||||
|
expect(messageBlocks(message([kept('m3', {})]), true)).toMatchObject([{ kind: 'steps' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-PARTS-008b: an answer that names none of the places it found shows no strip', () => {
|
||||||
|
const blocks = messageBlocks(
|
||||||
|
message([places('s1', [found('s1:0', { name: 'Café Ñandú' })]), { type: 'text', text: 'It rains all day.' }]),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(blocks.map((b) => b.kind)).toEqual(['text']);
|
||||||
|
// Named with other accents and case, it is the same place.
|
||||||
|
const named = messageBlocks(
|
||||||
|
message([places('s1', [found('s1:0', { name: 'Café Ñandú' })]), { type: 'text', text: 'Try cafe nandu.' }]),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(named.map((b) => b.kind)).toEqual(['places', 'text']);
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-AI-PARTS-009: drops the places a change of the model proposes, and the strip if none is left', () => {
|
it('FE-AI-PARTS-009: drops the places a change of the model proposes, and the strip if none is left', () => {
|
||||||
const blocks = messageBlocks(
|
const blocks = messageBlocks(
|
||||||
message([
|
message([
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type AssistantMessage,
|
type AssistantMessage,
|
||||||
type AssistantMessagePart,
|
type AssistantMessagePart,
|
||||||
type AssistantPlaces,
|
type AssistantPlaces,
|
||||||
|
type AssistantSources,
|
||||||
type AssistantWeatherDay,
|
type AssistantWeatherDay,
|
||||||
type TrekCard,
|
type TrekCard,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
@@ -66,6 +67,10 @@ type MessageBlock =
|
|||||||
| { kind: 'change'; key: string; items: ChangeItem[] }
|
| { kind: 'change'; key: string; items: ChangeItem[] }
|
||||||
| { kind: 'places'; key: string; found: AssistantPlaces }
|
| { kind: 'places'; key: string; found: AssistantPlaces }
|
||||||
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] }
|
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] }
|
||||||
|
// A note the model kept in the trip notes on its own, as a quiet line.
|
||||||
|
| { kind: 'remembered'; key: string; note: string }
|
||||||
|
// The pages the answer cites from the provider's web search, under its text.
|
||||||
|
| { kind: 'sources'; key: string; sources: AssistantSources['sources'] }
|
||||||
| CardsBlock;
|
| CardsBlock;
|
||||||
|
|
||||||
/** How cards are laid out: the group each joins, and which ones give way to a change they served. */
|
/** How cards are laid out: the group each joins, and which ones give way to a change they served. */
|
||||||
@@ -93,6 +98,77 @@ function proposed(place: AssistantFoundPlace, effects: readonly AssistantChangeE
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A name as it is compared with the answer's words: lower case, without accents or marks. */
|
||||||
|
function folded(text: string): string {
|
||||||
|
return text.normalize('NFKD').replace(/\p{M}/gu, '').toLocaleLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The found places the answer names, first, then the rest; null when the
|
||||||
|
* answer is written and names none of them. A search the answer does not
|
||||||
|
* draw on is the model's own groundwork and shows no strip.
|
||||||
|
*/
|
||||||
|
function namedFirst(places: readonly AssistantFoundPlace[], text: string): AssistantFoundPlace[] | null {
|
||||||
|
if (text.trim() === '') return [...places];
|
||||||
|
const words = folded(text);
|
||||||
|
const named = places.filter((place) => {
|
||||||
|
const name = folded(place.name).trim();
|
||||||
|
return name.length >= 3 && words.includes(name);
|
||||||
|
});
|
||||||
|
if (named.length === 0) return null;
|
||||||
|
return [...named, ...places.filter((place) => !named.includes(place))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tools that plan or save a tour. An answer that ran one is about the
|
||||||
|
* tour, planned or not: the places its searches went through to find the
|
||||||
|
* points are the model's groundwork, not suggestions, and show no strip.
|
||||||
|
*/
|
||||||
|
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
|
||||||
|
'propose_hike',
|
||||||
|
'create_hike',
|
||||||
|
'propose_tour',
|
||||||
|
'create_tour',
|
||||||
|
'create_paddle_tour',
|
||||||
|
'create_ski_tour',
|
||||||
|
'update_tour',
|
||||||
|
'propose_city_walk',
|
||||||
|
'create_city_walk',
|
||||||
|
'plan_paddle_route',
|
||||||
|
'plan_ski_route',
|
||||||
|
'prepare_tour_change',
|
||||||
|
'save_tour_change',
|
||||||
|
'propose_round_hike',
|
||||||
|
'propose_hike_leg_way',
|
||||||
|
'import_wanderer_trail',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** The tool that keeps a note in the trip notes; kept at once, without a card. */
|
||||||
|
const REMEMBER_TOOL = 'remember_for_trip';
|
||||||
|
|
||||||
|
/** The note a call kept, once it is kept; null for any other call, one still running or one refused. */
|
||||||
|
function rememberedNote(part: Extract<AssistantMessagePart, { type: 'tool-call' }>): string | null {
|
||||||
|
if (part.toolName !== REMEMBER_TOOL || part.approval || part.result === undefined || part.isError) return null;
|
||||||
|
const note = (part.args as { note?: unknown }).note;
|
||||||
|
return typeof note === 'string' && note.trim() !== '' ? note.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The strip of the answer's found places, named ones first; null when it names none or none is left. */
|
||||||
|
function placesBlock(
|
||||||
|
message: AssistantMessage,
|
||||||
|
effects: readonly AssistantChangeEffect[],
|
||||||
|
index: number
|
||||||
|
): MessageBlock | null {
|
||||||
|
const found = allFound(message.parts);
|
||||||
|
const text = message.parts.map((p) => (p.type === 'text' ? p.text : '')).join(' ');
|
||||||
|
const places = namedFirst(
|
||||||
|
found.places.filter((place) => !proposed(place, effects)),
|
||||||
|
text
|
||||||
|
);
|
||||||
|
if (!places || places.length === 0) return null;
|
||||||
|
return { kind: 'places', key: `places-${index}`, found: { ...found, places } };
|
||||||
|
}
|
||||||
|
|
||||||
/** Every day the answer's weather steps looked up, one entry per date (the later look wins), by date. */
|
/** Every day the answer's weather steps looked up, one entry per date (the later look wins), by date. */
|
||||||
function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDay[] {
|
function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDay[] {
|
||||||
const byDate = new Map<string, AssistantWeatherDay>();
|
const byDate = new Map<string, AssistantWeatherDay>();
|
||||||
@@ -256,7 +332,9 @@ export function messageBlocks(
|
|||||||
|
|
||||||
// An answer about a tour shows the tour, not the places its searches went through to find it.
|
// An answer about a tour shows the tour, not the places its searches went through to find it.
|
||||||
const changeCards = message.parts.flatMap((p) => (p.type === 'data-trek-change' && p.data.card ? [p.data.card] : []));
|
const changeCards = message.parts.flatMap((p) => (p.type === 'data-trek-change' && p.data.card ? [p.data.card] : []));
|
||||||
const placesHidden = [...cards.values(), ...changeCards].some((card) => rules.hidesFoundPlaces?.(card) === true);
|
const placesHidden =
|
||||||
|
[...cards.values(), ...changeCards].some((card) => rules.hidesFoundPlaces?.(card) === true) ||
|
||||||
|
message.parts.some((p) => p.type === 'tool-call' && TOUR_PLANNING_TOOLS.has(p.toolName));
|
||||||
const blocks: MessageBlock[] = [];
|
const blocks: MessageBlock[] = [];
|
||||||
message.parts.forEach((part, index) => {
|
message.parts.forEach((part, index) => {
|
||||||
if (part.type === 'text') {
|
if (part.type === 'text') {
|
||||||
@@ -264,9 +342,12 @@ export function messageBlocks(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (part.type === 'data-trek-places' && index === lastPlaces && !placesHidden) {
|
if (part.type === 'data-trek-places' && index === lastPlaces && !placesHidden) {
|
||||||
const found = allFound(message.parts);
|
const strip = placesBlock(message, effects, index);
|
||||||
const places = found.places.filter((place) => !proposed(place, effects));
|
if (strip) blocks.push(strip);
|
||||||
if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...found, places } });
|
return;
|
||||||
|
}
|
||||||
|
if (part.type === 'data-trek-sources') {
|
||||||
|
blocks.push({ kind: 'sources', key: `sources-${index}`, sources: part.data.sources });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (part.type === 'data-trek-weather' && index === firstWeather) {
|
if (part.type === 'data-trek-weather' && index === firstWeather) {
|
||||||
@@ -286,6 +367,11 @@ export function messageBlocks(
|
|||||||
pushChange(blocks, item, fromStrip);
|
pushChange(blocks, item, fromStrip);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const note = rememberedNote(part);
|
||||||
|
if (note !== null) {
|
||||||
|
blocks.push({ kind: 'remembered', key: `remembered-${part.toolCallId}`, note });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const step: ToolStep = {
|
const step: ToolStep = {
|
||||||
toolCallId: part.toolCallId,
|
toolCallId: part.toolCallId,
|
||||||
toolName: part.toolName,
|
toolName: part.toolName,
|
||||||
@@ -374,4 +460,7 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
|||||||
members: { labelKey: 'assistant.change.label.members' },
|
members: { labelKey: 'assistant.change.label.members' },
|
||||||
route: { labelKey: 'assistant.change.label.route' },
|
route: { labelKey: 'assistant.change.label.route' },
|
||||||
difficulty: { labelKey: 'assistant.change.label.difficulty' },
|
difficulty: { labelKey: 'assistant.change.label.difficulty' },
|
||||||
|
track: { labelKey: 'assistant.change.label.track' },
|
||||||
|
packing: { labelKey: 'assistant.change.label.packing' },
|
||||||
|
breaks: { labelKey: 'assistant.change.label.breaks' },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ 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);
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ function BodySlot({ item, settled }: { item: ChangeItem; settled: boolean }) {
|
|||||||
const view = item.card ? viewOf(item.card) : null;
|
const view = item.card ? viewOf(item.card) : null;
|
||||||
if (!view?.Body || !item.card) return null;
|
if (!view?.Body || !item.card) return null;
|
||||||
return (
|
return (
|
||||||
<div className={`pb-1.5 ps-[38px] ${settled ? 'opacity-60' : ''}`}>
|
<div className={`mt-1.5 pb-1.5 ${view.wideBody ? '' : 'ps-[38px]'} ${settled ? 'opacity-60' : ''}`}>
|
||||||
<Slot item={item} view={view} name="Body" />
|
<Slot item={item} view={view} name="Body" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AssistantMessage, AtlasCountryCard as CountryCard } from '@trek/shared';
|
import type { AssistantMessage, AtlasCountryCard as CountryCard } from '@trek/shared';
|
||||||
import { Briefcase, MapPin, Radio } from 'lucide-react';
|
import { Briefcase, Globe2, MapPin, Radio } from 'lucide-react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../../i18n';
|
import { useTranslation } from '../../../../i18n';
|
||||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||||
@@ -8,8 +8,10 @@ import type { ChangeItem } from '../../assistantParts';
|
|||||||
import type { CardEntry } from '../cardTypes';
|
import type { CardEntry } from '../cardTypes';
|
||||||
import { CardFacts } from '../parts/CardFacts';
|
import { CardFacts } from '../parts/CardFacts';
|
||||||
import CardFrame from '../parts/CardFrame';
|
import CardFrame from '../parts/CardFrame';
|
||||||
|
import CardHeader from '../parts/CardHeader';
|
||||||
import { CardOpenButton } from '../parts/CardOpen';
|
import { CardOpenButton } from '../parts/CardOpen';
|
||||||
import { CardMore } from '../parts/CardRows';
|
import { CardMore } from '../parts/CardRows';
|
||||||
|
import { CardFoot } from '../parts/CardSection';
|
||||||
import { CONTINENT_LABEL, continentOf, shortDay, STATUS_LOOK } from './atlasCardModel';
|
import { CONTINENT_LABEL, continentOf, shortDay, STATUS_LOOK } from './atlasCardModel';
|
||||||
import { ATLAS_PAGE } from './atlasPage';
|
import { ATLAS_PAGE } from './atlasPage';
|
||||||
import { ChangeFlag, FlagTile, StatusFact } from './AtlasParts';
|
import { ChangeFlag, FlagTile, StatusFact } from './AtlasParts';
|
||||||
@@ -112,14 +114,15 @@ export default function AtlasCountryBlock({ cards }: { cards: readonly CardEntry
|
|||||||
if (countries.length === 0) return null;
|
if (countries.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<CardFrame label={t('assistant.domain.atlas')}>
|
<CardFrame label={t('assistant.domain.atlas')}>
|
||||||
|
<CardHeader icon={Globe2} tone="info" title={t('assistant.domain.atlas')} />
|
||||||
{countries.map((entry, index) => (
|
{countries.map((entry, index) => (
|
||||||
<div key={entry.toolCallId} className={index > 0 ? 'mt-2 border-t border-edge-faint pt-2' : ''}>
|
<div key={entry.toolCallId} className={index > 0 ? 'mt-2 border-t border-edge-faint pt-2' : ''}>
|
||||||
{entry.card.kind === 'atlasCountry' && <CountrySection card={entry.card} />}
|
{entry.card.kind === 'atlasCountry' && <CountrySection card={entry.card} />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="mt-1 flex justify-end">
|
<CardFoot>
|
||||||
<CardOpenButton target={ATLAS_PAGE} labelKey="assistant.card.atlas.open" />
|
<CardOpenButton target={ATLAS_PAGE} labelKey="assistant.card.atlas.open" />
|
||||||
</div>
|
</CardFoot>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-016
|
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-017
|
||||||
*
|
*
|
||||||
* 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 { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
|
import { BOOK_FINDING_CODES, blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -67,6 +67,9 @@ 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');
|
||||||
@@ -92,6 +95,15 @@ 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,6 +10,7 @@ import {
|
|||||||
type BookSpread,
|
type BookSpread,
|
||||||
findBookGrid,
|
findBookGrid,
|
||||||
findBookTemplate,
|
findBookTemplate,
|
||||||
|
findDecoratedTemplate,
|
||||||
setBookCover,
|
setBookCover,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
|
|
||||||
@@ -57,6 +58,15 @@ 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. */
|
||||||
@@ -82,7 +92,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);
|
const template = findBookTemplate(layout.id) ?? findDecoratedTemplate(layout.id);
|
||||||
return template ? t(template.labelKey) : null;
|
return template ? t(template.labelKey) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ 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, Ruler, Sparkles } from 'lucide-react';
|
import { BookmarkMinus, BookmarkPlus, Copy, Palette, Ruler, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
import type { PackLook } from '../cardTypes';
|
import type { PackLook } from '../cardTypes';
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ 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,6 +24,19 @@ 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;
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ export function ExpenseFacts({ card }: SlotProps) {
|
|||||||
<>
|
<>
|
||||||
<CategoryFact row={row} />
|
<CategoryFact row={row} />
|
||||||
<DateFact row={row} />
|
<DateFact row={row} />
|
||||||
{row.place && <PlaceFact place={row.place} />}
|
|
||||||
{split && (
|
{split && (
|
||||||
<FactChange
|
<FactChange
|
||||||
icon={Tag}
|
icon={Tag}
|
||||||
@@ -92,13 +91,16 @@ export function ExpenseFacts({ card }: SlotProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The split under the row, and the expense's note under it: too long for the faint line beside the icons. */
|
/** Under the row: the place it belongs to beside the split, and the expense's note under them. */
|
||||||
export function ExpenseBody({ card }: SlotProps) {
|
export function ExpenseBody({ card }: SlotProps) {
|
||||||
const row = card.rows[0];
|
const row = card.rows[0];
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return (
|
return (
|
||||||
<span className="flex flex-col items-start gap-1">
|
<span className="flex flex-col items-start gap-1.5">
|
||||||
<SplitLine row={row} />
|
<span className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{row.place && <PlaceFact place={row.place} />}
|
||||||
|
<SplitLine row={row} />
|
||||||
|
</span>
|
||||||
{row.note && <NotePill note={row.note} />}
|
{row.note && <NotePill note={row.note} />}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -121,8 +121,7 @@ describe('an expense in a change row', () => {
|
|||||||
expect(screen.getByText(money(84))).toHaveClass('line-through');
|
expect(screen.getByText(money(84))).toHaveClass('line-through');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-AI-CARD-BUD-012: the facts name the category, day and place, and what an update changes', () => {
|
it('FE-AI-CARD-BUD-012: the facts name the category and day, and what an update changes', () => {
|
||||||
const onOpen = vi.fn();
|
|
||||||
const update = expense({
|
const update = expense({
|
||||||
category: 'transport',
|
category: 'transport',
|
||||||
date: '2026-06-05',
|
date: '2026-06-05',
|
||||||
@@ -132,7 +131,7 @@ describe('an expense in a change row', () => {
|
|||||||
split: 'equal',
|
split: 'equal',
|
||||||
before: { name: null, amount: null, category: 'food', date: '2026-06-03', split: 'custom' },
|
before: { name: null, amount: null, category: 'food', date: '2026-06-03', split: 'custom' },
|
||||||
});
|
});
|
||||||
inView(<ExpenseFacts card={expenses([update])} item={changeItem('a', null)} />, { onOpen });
|
inView(<ExpenseFacts card={expenses([update])} item={changeItem('a', null)} />);
|
||||||
expect(screen.getByText('Food & drink')).toHaveClass('line-through');
|
expect(screen.getByText('Food & drink')).toHaveClass('line-through');
|
||||||
expect(screen.getByText('Transport')).toBeInTheDocument();
|
expect(screen.getByText('Transport')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Split unevenly')).toHaveClass('line-through');
|
expect(screen.getByText('Split unevenly')).toHaveClass('line-through');
|
||||||
@@ -140,6 +139,14 @@ describe('an expense in a change row', () => {
|
|||||||
expect(screen.getByText('1 receipt')).toBeInTheDocument();
|
expect(screen.getByText('1 receipt')).toBeInTheDocument();
|
||||||
// The note is too long for the faint line beside the row's buttons: it sits under the row.
|
// The note is too long for the faint line beside the row's buttons: it sits under the row.
|
||||||
expect(screen.queryByText('Window table')).toBeNull();
|
expect(screen.queryByText('Window table')).toBeNull();
|
||||||
|
// The place sits under the row, beside the split.
|
||||||
|
expect(screen.queryByText('Lokanta')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-CARD-BUD-012c: the place sits under the row beside the split, and opens on the map', () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
const row = expense({ place: { id: 12, name: 'Lokanta' } });
|
||||||
|
inView(<ExpenseBody card={expenses([row])} item={changeItem('a', null)} />, { onOpen });
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Lokanta/ }));
|
fireEvent.click(screen.getByRole('button', { name: /Lokanta/ }));
|
||||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 12, dayId: null });
|
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 12, dayId: null });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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';
|
||||||
@@ -129,6 +130,8 @@ export const TOOL_LABELS: Readonly<Partial<Record<string, { labelKey: string }>>
|
|||||||
/** How each reading tool is named while it runs; a tool not listed reads "Working on it". */
|
/** How each reading tool is named while it runs; a tool not listed reads "Working on it". */
|
||||||
export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
|
export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
|
||||||
get_trip_summary: { stepKey: 'assistant.step.reading' },
|
get_trip_summary: { stepKey: 'assistant.step.reading' },
|
||||||
|
// The provider's own web search, told as a step of its own (ASSISTANT_WEB_SEARCH_STEP).
|
||||||
|
web_search: { stepKey: 'assistant.step.webSearch', live: { liveKey: 'assistant.step.webSearchFor', param: 'query' } },
|
||||||
list_places: { stepKey: 'assistant.step.reading' },
|
list_places: { stepKey: 'assistant.step.reading' },
|
||||||
search_place: {
|
search_place: {
|
||||||
stepKey: 'assistant.step.searching',
|
stepKey: 'assistant.step.searching',
|
||||||
@@ -168,6 +171,7 @@ 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. */
|
||||||
@@ -232,6 +236,7 @@ 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. */
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export interface CardView<K extends TrekCardKind> {
|
|||||||
Facts?: ComponentType<CardSlotProps<K>>;
|
Facts?: ComponentType<CardSlotProps<K>>;
|
||||||
Body?: ComponentType<CardSlotProps<K>>;
|
Body?: ComponentType<CardSlotProps<K>>;
|
||||||
ownsSubject?: boolean;
|
ownsSubject?: boolean;
|
||||||
|
/** The body runs the whole width of the change card instead of under the row's text (a tour's drawing). */
|
||||||
|
wideBody?: boolean;
|
||||||
Group?: ComponentType<CardRunProps & { place: 'head' | 'foot' }>;
|
Group?: ComponentType<CardRunProps & { place: 'head' | 'foot' }>;
|
||||||
Run?: ComponentType<CardRunProps>;
|
Run?: ComponentType<CardRunProps>;
|
||||||
runnable?(card: TrekCardOf<K>): boolean;
|
runnable?(card: TrekCardOf<K>): boolean;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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';
|
||||||
@@ -33,6 +34,7 @@ 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. */
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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'>;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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> };
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import type { AssistantMessage, JourneyEntryLook } from '@trek/shared';
|
import type { AssistantMessage, JourneyEntryLook } from '@trek/shared';
|
||||||
import { ArrowUpRight } from 'lucide-react';
|
import { ArrowUpRight, BookOpen } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../../i18n';
|
import { useTranslation } from '../../../../i18n';
|
||||||
import { DAY_COLORS } from '../../../Journey/dayColors';
|
import { DAY_COLORS } from '../../../Journey/dayColors';
|
||||||
import { cardTitle } from '../../../Journey/journeyCard';
|
import { cardTitle } from '../../../Journey/journeyCard';
|
||||||
import JourneyEntryCover from '../../../Journey/JourneyEntryCover';
|
import JourneyEntryCover from '../../../Journey/JourneyEntryCover';
|
||||||
import { LinkedJournalIcon } from '../../../Trips/LinkedJournalIcon';
|
|
||||||
import { useAssistantView } from '../../assistantViewContext';
|
import { useAssistantView } from '../../assistantViewContext';
|
||||||
import type { CardEntry } from '../cardTypes';
|
import type { CardEntry } from '../cardTypes';
|
||||||
import CardFrame from '../parts/CardFrame';
|
import CardFrame from '../parts/CardFrame';
|
||||||
|
import CardHeader from '../parts/CardHeader';
|
||||||
|
import { CardFoot } from '../parts/CardSection';
|
||||||
import CardStrip from '../parts/CardStrip';
|
import CardStrip from '../parts/CardStrip';
|
||||||
import { type JournalEntries, coverEntryOf, mergeJournalCards, titleSourceOf } from './journeyCardModel';
|
import { type JournalEntries, coverEntryOf, mergeJournalCards, titleSourceOf } from './journeyCardModel';
|
||||||
import { EntryMarks, EntryStory, EntryThumb, EntryWhen } from './JourneyEntryParts';
|
import { EntryMarks, EntryStory, EntryThumb, EntryWhen } from './JourneyEntryParts';
|
||||||
@@ -33,9 +34,6 @@ const TITLE_BUTTON =
|
|||||||
'block w-full rounded-md text-start outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent';
|
'block w-full rounded-md text-start outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent';
|
||||||
/** The phone's taller target, the title still at the top of the row. */
|
/** The phone's taller target, the title still at the top of the row. */
|
||||||
const TITLE_BUTTON_TALL = `${TITLE_BUTTON} min-h-10`;
|
const TITLE_BUTTON_TALL = `${TITLE_BUTTON} min-h-10`;
|
||||||
const FOOTER = 'mt-1 flex items-center gap-2 border-t border-edge-faint pt-2 text-caption';
|
|
||||||
const FOOTER_TITLE = 'min-w-0 flex-1 truncate text-content-muted';
|
|
||||||
const FAINT = 'text-content-faint';
|
|
||||||
const OPEN = 'inline-flex flex-none items-center gap-1 font-medium text-accent hover:underline';
|
const OPEN = 'inline-flex flex-none items-center gap-1 font-medium text-accent hover:underline';
|
||||||
const OPEN_TALL = 'inline-flex h-10 flex-none items-center gap-1 font-medium text-accent hover:underline';
|
const OPEN_TALL = 'inline-flex h-10 flex-none items-center gap-1 font-medium text-accent hover:underline';
|
||||||
/** The size and corners of the journal's own tile, so the count stands in the row like one more entry. */
|
/** The size and corners of the journal's own tile, so the count stands in the row like one more entry. */
|
||||||
@@ -47,29 +45,40 @@ const TILE = 'flex-none snap-start';
|
|||||||
|
|
||||||
type Open = ((entryId: number | null) => void) | null;
|
type Open = ((entryId: number | null) => void) | null;
|
||||||
|
|
||||||
/** The journal's name and the way into it, under the card. */
|
/**
|
||||||
function JournalFooter({ journal, open }: { journal: JournalEntries; open: Open }) {
|
* The way into the journal under its entries: in the foot bar of the card, or
|
||||||
|
* under the strip of tiles with how many entries it holds, which the strip
|
||||||
|
* has no band to say.
|
||||||
|
*/
|
||||||
|
function JournalFooter({ open, count }: { open: Open; count?: number }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { variant } = useAssistantView();
|
const { variant } = useAssistantView();
|
||||||
const title = journal.journalTitle ?? t('assistant.card.journey.label');
|
const button = open && (
|
||||||
const count = journal.entries.length + journal.more;
|
<button type="button" onClick={() => open(null)} className={variant === 'fullscreen' ? OPEN_TALL : OPEN}>
|
||||||
|
{t('assistant.card.journey.open')}
|
||||||
|
<ArrowUpRight size={12} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
if (count === undefined) return button ? <CardFoot>{button}</CardFoot> : null;
|
||||||
return (
|
return (
|
||||||
<div className={FOOTER}>
|
<div className="mt-1 flex items-center gap-2 text-caption">
|
||||||
<LinkedJournalIcon size={12} className={FAINT} />
|
<span className="min-w-0 flex-1 truncate tabular-nums text-content-muted">
|
||||||
<span title={title} className={FOOTER_TITLE}>
|
{t('assistant.card.journey.entries', { count })}
|
||||||
<bdi>{title}</bdi>
|
|
||||||
{count > 2 && <span className="ms-1.5 tabular-nums">{t('assistant.card.journey.entries', { count })}</span>}
|
|
||||||
</span>
|
</span>
|
||||||
{open && (
|
{button}
|
||||||
<button type="button" onClick={() => open(null)} className={variant === 'fullscreen' ? OPEN_TALL : OPEN}>
|
|
||||||
{t('assistant.card.journey.open')}
|
|
||||||
<ArrowUpRight size={12} aria-hidden="true" className="rtl:-scale-x-100" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The band of the entries: the journal's name, and how many entries it holds once there are more than two. */
|
||||||
|
function JournalHead({ journal }: { journal: JournalEntries }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const title = journal.journalTitle ?? t('assistant.card.journey.label');
|
||||||
|
const count = journal.entries.length + journal.more;
|
||||||
|
const meta = count > 2 ? t('assistant.card.journey.entries', { count }) : undefined;
|
||||||
|
return <CardHeader icon={BookOpen} tone="info" title={title} meta={meta} />;
|
||||||
|
}
|
||||||
|
|
||||||
/** One entry as a row: its photo, its title (the way to it), its facts and the start of its text. */
|
/** One entry as a row: its photo, its title (the way to it), its facts and the start of its text. */
|
||||||
function EntryRow({
|
function EntryRow({
|
||||||
look,
|
look,
|
||||||
@@ -116,10 +125,11 @@ function JournalRows({ journal, fallbackRef }: { journal: JournalEntries; fallba
|
|||||||
const hover = useEntryHover(journal.journeyId, fallbackRef);
|
const hover = useEntryHover(journal.journeyId, fallbackRef);
|
||||||
return (
|
return (
|
||||||
<CardFrame label={t('assistant.card.journey.label')}>
|
<CardFrame label={t('assistant.card.journey.label')}>
|
||||||
|
<JournalHead journal={journal} />
|
||||||
{journal.entries.map((look, index) => (
|
{journal.entries.map((look, index) => (
|
||||||
<EntryRow key={look.entryId ?? `new-${index}`} look={look} divided={index > 0} open={open} hover={hover} />
|
<EntryRow key={look.entryId ?? `new-${index}`} look={look} divided={index > 0} open={open} hover={hover} />
|
||||||
))}
|
))}
|
||||||
<JournalFooter journal={journal} open={open} />
|
<JournalFooter open={open} />
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -152,7 +162,7 @@ function JournalStrip({ journal, fallbackRef }: { journal: JournalEntries; fallb
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</CardStrip>
|
</CardStrip>
|
||||||
<JournalFooter journal={journal} open={open} />
|
<JournalFooter open={open} count={journal.entries.length + journal.more} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import type { PackingListCard as PackingCard, PackingCardGroup } from '@trek/shared';
|
import type { PackingListCard as PackingCard, PackingCardGroup } from '@trek/shared';
|
||||||
import { Check } from 'lucide-react';
|
import { Backpack, Check } from 'lucide-react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../../i18n';
|
import { useTranslation } from '../../../../i18n';
|
||||||
import { SoftPill } from '../../../Planner/planParts';
|
import { SoftPill } from '../../../Planner/planParts';
|
||||||
import { PackedBar, PackTile } from '../../../Tours/packing/packingLinkParts';
|
import { PackedBar } from '../../../Tours/packing/packingLinkParts';
|
||||||
import AssistantChangeFields from '../../AssistantChangeFields';
|
import AssistantChangeFields from '../../AssistantChangeFields';
|
||||||
import type { ChangeItem } from '../../assistantParts';
|
import type { ChangeItem } from '../../assistantParts';
|
||||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||||
import CardFrame from '../parts/CardFrame';
|
import CardFrame from '../parts/CardFrame';
|
||||||
import { isRunnable, mergePackingCards, packingColorOf, rowCount } from './checklistModel';
|
import CardHeader from '../parts/CardHeader';
|
||||||
|
import { CardFoot } from '../parts/CardSection';
|
||||||
|
import { isRunnable, mergePackingCards, rowCount } from './checklistModel';
|
||||||
import { ChecklistFoot, ChecklistRun, type RunEntry, type RunProps } from './checklistParts';
|
import { ChecklistFoot, ChecklistRun, type RunEntry, type RunProps } from './checklistParts';
|
||||||
import { PackingGroup, PackingRowAside, PackingRowFacts } from './packingParts';
|
import { PackingGroup, PackingRowAside, PackingRowFacts } from './packingParts';
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ function useTitle(card: PackingCard): string {
|
|||||||
: t('assistant.card.packing.title');
|
: t('assistant.card.packing.title');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The head of an answer: the tile with the count, the title, how much is packed, and "All packed" once it is. */
|
/** The head band of an answer: the backpack, the title, how much is packed with its bar, and "All packed" once it is. */
|
||||||
function PackingHead({ card }: { card: PackingCard }) {
|
function PackingHead({ card }: { card: PackingCard }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const title = useTitle(card);
|
const title = useTitle(card);
|
||||||
@@ -73,50 +75,46 @@ function PackingHead({ card }: { card: PackingCard }) {
|
|||||||
if (source.type === 'tour') caption = t('assistant.card.packing.forTour');
|
if (source.type === 'tour') caption = t('assistant.card.packing.forTour');
|
||||||
else if (source.type === 'template') caption = t('assistant.card.packing.templateAdds', { count: source.itemCount });
|
else if (source.type === 'template') caption = t('assistant.card.packing.templateAdds', { count: source.itemCount });
|
||||||
else if (card.progress && total > 0) caption = t('tours.packing.packed', { packed, count: total });
|
else if (card.progress && total > 0) caption = t('tours.packing.packed', { packed, count: total });
|
||||||
const first = card.groups[0]?.key;
|
const done = card.progress !== null && total > 0 && packed === total;
|
||||||
const color =
|
|
||||||
source.type === 'tour' && source.category !== null && first?.by === 'category'
|
|
||||||
? packingColorOf(first.slot, source.category)
|
|
||||||
: null;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<CardHeader
|
||||||
<PackTile count={source.type === 'template' ? source.itemCount : total} color={color} />
|
icon={Backpack}
|
||||||
<div className="min-w-0 flex-1">
|
tone={done ? 'success' : 'info'}
|
||||||
<p title={title} className="m-0 line-clamp-2 text-body font-semibold leading-tight text-content">
|
title={title}
|
||||||
{title}
|
count={source.type === 'template' ? source.itemCount : total}
|
||||||
</p>
|
meta={caption ?? undefined}
|
||||||
{caption && (
|
actions={
|
||||||
<p title={caption} className="m-0 truncate text-caption text-content-faint">
|
done ? (
|
||||||
{caption}
|
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
||||||
</p>
|
{t('tours.packing.allPacked')}
|
||||||
)}
|
</SoftPill>
|
||||||
</div>
|
) : undefined
|
||||||
{card.progress && total > 0 && packed === total && (
|
}
|
||||||
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
>
|
||||||
{t('tours.packing.allPacked')}
|
{card.progress && card.progress.total > 0 && (
|
||||||
</SoftPill>
|
<div className="mt-2.5">
|
||||||
|
<PackedBar packed={card.progress.packed} total={card.progress.total} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardHeader>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One answer card. */
|
/** One answer card. */
|
||||||
function PackingAnswer({ card }: { card: PackingCard }) {
|
function PackingAnswer({ card }: { card: PackingCard }) {
|
||||||
const title = useTitle(card);
|
const title = useTitle(card);
|
||||||
const progress = card.progress;
|
|
||||||
return (
|
return (
|
||||||
<CardFrame label={title}>
|
<CardFrame label={title}>
|
||||||
<div className="flex min-w-0 flex-col gap-2.5">
|
<PackingHead card={card} />
|
||||||
<PackingHead card={card} />
|
<PackingGroups card={card} inChange={false} />
|
||||||
{progress && progress.total > 0 && <PackedBar packed={progress.packed} total={progress.total} />}
|
<CardFoot>
|
||||||
<PackingGroups card={card} inChange={false} />
|
|
||||||
<ChecklistFoot
|
<ChecklistFoot
|
||||||
more={card.moreGroups}
|
more={card.moreGroups}
|
||||||
moreKey={MORE_GROUPS[card.groupBy].labelKey}
|
moreKey={MORE_GROUPS[card.groupBy].labelKey}
|
||||||
target={PACKING_TARGET}
|
target={PACKING_TARGET}
|
||||||
openKey="tours.packing.open"
|
openKey="tours.packing.open"
|
||||||
/>
|
/>
|
||||||
</div>
|
</CardFoot>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts'
|
|||||||
import { localToday } from '../../../Planner/today';
|
import { localToday } from '../../../Planner/today';
|
||||||
import { Tooltip } from '../../../shared/Tooltip';
|
import { Tooltip } from '../../../shared/Tooltip';
|
||||||
import { PRIO_CONFIG } from '../../../Todo/todoListModel';
|
import { PRIO_CONFIG } from '../../../Todo/todoListModel';
|
||||||
import { PackTile } from '../../../Tours/packing/packingLinkParts';
|
|
||||||
import AssistantChangeFields from '../../AssistantChangeFields';
|
import AssistantChangeFields from '../../AssistantChangeFields';
|
||||||
import type { ChangeItem } from '../../assistantParts';
|
import type { ChangeItem } from '../../assistantParts';
|
||||||
import { useAssistantView } from '../../assistantViewContext';
|
import { useAssistantView } from '../../assistantViewContext';
|
||||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||||
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
||||||
import CardFrame from '../parts/CardFrame';
|
import CardFrame from '../parts/CardFrame';
|
||||||
|
import CardHeader from '../parts/CardHeader';
|
||||||
import { PersonFace } from '../parts/CardPeople';
|
import { PersonFace } from '../parts/CardPeople';
|
||||||
|
import { CardFoot } from '../parts/CardSection';
|
||||||
import { useCardFormat } from '../parts/useCardFormat';
|
import { useCardFormat } from '../parts/useCardFormat';
|
||||||
import { isRunnable, mergeTodoCards, todoColorOf } from './checklistModel';
|
import { isRunnable, mergeTodoCards, todoColorOf } from './checklistModel';
|
||||||
import {
|
import {
|
||||||
@@ -204,27 +205,30 @@ function TodoAnswer({ card }: { card: TodoCard }) {
|
|||||||
const title = t('assistant.card.todo.title');
|
const title = t('assistant.card.todo.title');
|
||||||
return (
|
return (
|
||||||
<CardFrame label={title}>
|
<CardFrame label={title}>
|
||||||
<div className="flex min-w-0 flex-col gap-2.5">
|
<CardHeader
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
icon={ListTodo}
|
||||||
<PackTile count={progress?.open ?? 0} color={null} icon={ListTodo} />
|
tone={progress && progress.overdue > 0 ? 'danger' : 'info'}
|
||||||
<p className="m-0 text-body font-semibold leading-tight text-content">{title}</p>
|
title={title}
|
||||||
{progress && <SoftPill>{t('assistant.card.todo.openCount', { count: progress.open })}</SoftPill>}
|
meta={progress ? t('assistant.card.todo.openCount', { count: progress.open }) : undefined}
|
||||||
{progress && progress.overdue > 0 && (
|
actions={
|
||||||
|
progress && progress.overdue > 0 ? (
|
||||||
<SoftPill tone="danger">{t('assistant.card.todo.overdueCount', { count: progress.overdue })}</SoftPill>
|
<SoftPill tone="danger">{t('assistant.card.todo.overdueCount', { count: progress.overdue })}</SoftPill>
|
||||||
)}
|
) : undefined
|
||||||
</div>
|
}
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
/>
|
||||||
{card.groups.map((group) => (
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<TodoGroup key={group.category ?? ''} group={group} inChange={false} />
|
{card.groups.map((group) => (
|
||||||
))}
|
<TodoGroup key={group.category ?? ''} group={group} inChange={false} />
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
<CardFoot>
|
||||||
<ChecklistFoot
|
<ChecklistFoot
|
||||||
more={card.moreGroups}
|
more={card.moreGroups}
|
||||||
moreKey="assistant.card.todo.moreLists"
|
moreKey="assistant.card.todo.moreLists"
|
||||||
target={TODO_TARGET}
|
target={TODO_TARGET}
|
||||||
openKey="assistant.card.todo.open"
|
openKey="assistant.card.todo.open"
|
||||||
/>
|
/>
|
||||||
</div>
|
</CardFoot>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,16 @@ 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A group's head: its colour, its name, a badge, and who looks after it (the ones a change drops faded). */
|
/** The band behind a group's head: its colour faint over the card's grey, the grey alone without one. */
|
||||||
|
function bandOf(color: string): string {
|
||||||
|
return color ? `color-mix(in srgb, ${color} 12%, var(--bg-secondary))` : 'var(--bg-secondary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A group's head as a section of the list: a full width band in its colour,
|
||||||
|
* the colour dot, its name, and at the end its badge (how many it holds) and
|
||||||
|
* who looks after it, the ones a change drops faded.
|
||||||
|
*/
|
||||||
export function ChecklistHead({
|
export function ChecklistHead({
|
||||||
color,
|
color,
|
||||||
name,
|
name,
|
||||||
@@ -38,14 +47,17 @@ export function ChecklistHead({
|
|||||||
lead?: ReactNode;
|
lead?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 items-center gap-2 pt-1">
|
<div
|
||||||
|
className="mt-1.5 flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5"
|
||||||
|
style={{ background: bandOf(color) }}
|
||||||
|
>
|
||||||
{lead ?? <CategoryDot color={color} />}
|
{lead ?? <CategoryDot color={color} />}
|
||||||
<bdi title={name} className="min-w-0 truncate text-caption font-semibold text-content">
|
<bdi title={name} className="min-w-0 flex-1 truncate text-caption font-semibold text-content">
|
||||||
{name}
|
{name}
|
||||||
</bdi>
|
</bdi>
|
||||||
{badge}
|
{(badge || people.length > 0 || removed.length > 0) && (
|
||||||
{(people.length > 0 || removed.length > 0) && (
|
<span className="flex flex-none items-center gap-1.5">
|
||||||
<span className="ms-auto flex flex-none items-center gap-1.5">
|
{badge}
|
||||||
<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 && (
|
||||||
@@ -90,7 +102,7 @@ export function ChecklistRow({
|
|||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</bdi>
|
</bdi>
|
||||||
{aside && <span className="flex flex-none flex-wrap items-center justify-end gap-1">{aside}</span>}
|
{aside && <span className="flex flex-none flex-wrap items-center justify-end gap-x-2 gap-y-1">{aside}</span>}
|
||||||
</div>
|
</div>
|
||||||
{under}
|
{under}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { formatWeight } from '../../../Packing/packingListModel';
|
|||||||
import { bagFillPct } from '../../../Packing/packingListPanel.helpers';
|
import { bagFillPct } from '../../../Packing/packingListPanel.helpers';
|
||||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||||
import { Tooltip } from '../../../shared/Tooltip';
|
import { Tooltip } from '../../../shared/Tooltip';
|
||||||
import { CountBadge } from '../../../Tours/packing/packingLinkParts';
|
|
||||||
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
||||||
import { PeopleStack, PersonFace } from '../parts/CardPeople';
|
import { PeopleStack, PersonFace } from '../parts/CardPeople';
|
||||||
import { packingColorOf } from './checklistModel';
|
import { packingColorOf } from './checklistModel';
|
||||||
@@ -56,15 +55,31 @@ function BagSwatch({ bag, size = 'row' }: { bag: PackingCardBag | undefined; siz
|
|||||||
* What a row says at its end: how many, how many packed, its weight, its tier
|
* What a row says at its end: how many, how many packed, its weight, its tier
|
||||||
* and its bag. A bag the card does not carry draws nothing rather than "Unassigned".
|
* and its bag. A bag the card does not carry draws nothing rather than "Unassigned".
|
||||||
*/
|
*/
|
||||||
|
/** A row's quantity and weight: quiet figures at its end, no frame each. */
|
||||||
|
const QUIET = 'text-caption tabular-nums text-content-muted';
|
||||||
|
|
||||||
|
/** How many rows a group holds, a white pill at the end of its band. */
|
||||||
|
function GroupCount({ count }: { count: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={t('tours.packing.items', { count })}
|
||||||
|
className="inline-flex h-[18px] flex-none items-center rounded-full bg-surface-card px-1.5 text-caption font-semibold tabular-nums text-content-secondary shadow-sm"
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PackingRowAside({ row, bag }: { row: PackingCardItem; bag: PackingCardBag | undefined }) {
|
export function PackingRowAside({ row, bag }: { row: PackingCardItem; bag: PackingCardBag | undefined }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const personal = t('assistant.card.packing.tierPersonal');
|
const personal = t('assistant.card.packing.tierPersonal');
|
||||||
const partial = !row.checked && row.packed > 0 && row.packed < row.quantity;
|
const partial = !row.checked && row.packed > 0 && row.packed < row.quantity;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{row.quantity > 1 && <WhiteBadge>×{row.quantity}</WhiteBadge>}
|
{row.quantity > 1 && <span className={QUIET}>×{row.quantity}</span>}
|
||||||
{partial && <WhiteBadge>{`${row.packed}/${row.quantity}`}</WhiteBadge>}
|
{partial && <span className={QUIET}>{`${row.packed}/${row.quantity}`}</span>}
|
||||||
{row.weightGrams !== null && <WhiteBadge>{formatWeight(row.weightGrams)}</WhiteBadge>}
|
{row.weightGrams !== null && <span className={QUIET}>{formatWeight(row.weightGrams)}</span>}
|
||||||
{row.tier === 'personal' && (
|
{row.tier === 'personal' && (
|
||||||
<Tooltip label={personal}>
|
<Tooltip label={personal}>
|
||||||
<span role="img" aria-label={personal} className="flex text-content-faint">
|
<span role="img" aria-label={personal} className="flex text-content-faint">
|
||||||
@@ -216,7 +231,7 @@ function BagHead({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!bag) {
|
if (!bag) {
|
||||||
const badge = inChange ? undefined : <CountBadge count={group.count} />;
|
const badge = <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} />;
|
||||||
}
|
}
|
||||||
@@ -283,7 +298,7 @@ export function PackingGroup({
|
|||||||
<ChecklistHead
|
<ChecklistHead
|
||||||
color={packingColorOf(key.slot, name)}
|
color={packingColorOf(key.slot, name)}
|
||||||
name={name}
|
name={name}
|
||||||
badge={inChange ? undefined : <CountBadge count={group.count} />}
|
badge={<GroupCount count={group.count} />}
|
||||||
people={group.assignees}
|
people={group.assignees}
|
||||||
removed={group.assigneesRemoved}
|
removed={group.assigneesRemoved}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The frame every answer card sits in, the same as the change card's: a
|
* The frame every answer card sits in, the same as the change card's: a
|
||||||
* rounded card on the chat. A waiting change shows its frame dashed, a
|
* rounded card on the chat whose head band (CardHeader) runs to its edges. A waiting change shows its frame dashed, a
|
||||||
* removed one faded, a settled one (discarded, expired, undone) fainter
|
* removed one faded, a settled one (discarded, expired, undone) fainter
|
||||||
* still. The chat card (a message in the trip chat) sits on the quieter
|
* still. The chat card (a message in the trip chat) sits on the quieter
|
||||||
* secondary surface.
|
* secondary surface.
|
||||||
@@ -32,7 +32,7 @@ export default function CardFrame({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={`trek-assistant-enter rounded-xl border px-3 pb-2 pt-2.5 shadow-card ${STATE[state]} ${
|
className={`trek-assistant-enter overflow-hidden rounded-[16px] border px-3.5 pb-3 pt-3 shadow-card ${STATE[state]} ${
|
||||||
surface === 'secondary' ? 'bg-surface-secondary' : 'bg-surface-card'
|
surface === 'secondary' ? 'bg-surface-secondary' : 'bg-surface-card'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,43 +1,94 @@
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { WhiteBadge } from '../../../Planner/planParts';
|
import { fs, NEUTRAL_TINT } from '../../../shared/DialogShell';
|
||||||
import type { Tone } from './assistantTones';
|
import type { Tone } from './assistantTones';
|
||||||
import KindTile from './KindTile';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The head of an answer card: its tile, what it is about (wrapping to two
|
* The head band of an answer card, as the trip's dialogs and booking cards
|
||||||
* lines at most, the whole title in its tooltip), how many it holds, one
|
* wear it: a tinted band across the card's top, a raised white tile with the
|
||||||
* quiet line of context under it, and its actions at the end.
|
* icon of what the card is about in its tone, an eyebrow naming the kind,
|
||||||
|
* the title, one quiet line under it, a count, and the card's actions at the
|
||||||
|
* end. It reaches the card's edges, so a card that opens with it needs no
|
||||||
|
* other head.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** The icon's colour on the white tile, per tone. */
|
||||||
|
const GLYPH: Record<Tone, string> = {
|
||||||
|
success: 'text-success',
|
||||||
|
info: 'text-info',
|
||||||
|
warning: 'text-warning',
|
||||||
|
danger: 'text-danger',
|
||||||
|
};
|
||||||
|
|
||||||
export default function CardHeader({
|
export default function CardHeader({
|
||||||
icon,
|
icon: Icon,
|
||||||
tone,
|
tone,
|
||||||
title,
|
title,
|
||||||
|
eyebrow,
|
||||||
count,
|
count,
|
||||||
meta,
|
meta,
|
||||||
actions,
|
actions,
|
||||||
|
tile,
|
||||||
|
tint = NEUTRAL_TINT,
|
||||||
|
children,
|
||||||
}: {
|
}: {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
tone: Tone;
|
tone: Tone;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** The kind above the title ("PACKING LIST"); none when the title says it. */
|
||||||
|
eyebrow?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
meta?: ReactNode;
|
meta?: ReactNode;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
|
/** In place of the icon tile: a drawing of what the card shows (a route's sketch). */
|
||||||
|
tile?: ReactNode;
|
||||||
|
tint?: string;
|
||||||
|
/** Under the title row, on the band: a progress bar, a row of pills. */
|
||||||
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="-mx-3.5 -mt-3 mb-3 px-3.5 pb-2.5 pt-2.5" style={{ background: tint }}>
|
||||||
<KindTile icon={icon} tone={tone} size="sm" />
|
<div className={`flex gap-2.5 ${meta ? 'items-start' : 'items-center'}`}>
|
||||||
<div className="min-w-0 flex-1">
|
{tile ?? (
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
<span
|
||||||
<bdi title={title} className="line-clamp-2 text-body font-semibold leading-tight text-content">
|
aria-hidden="true"
|
||||||
{title}
|
className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm ${GLYPH[tone]}`}
|
||||||
</bdi>
|
>
|
||||||
{count !== undefined && <WhiteBadge>{count}</WhiteBadge>}
|
<Icon size={15} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{eyebrow && (
|
||||||
|
<div
|
||||||
|
className="truncate font-geist font-bold uppercase tracking-[.08em] text-content-muted"
|
||||||
|
style={fs(9.5)}
|
||||||
|
>
|
||||||
|
{eyebrow}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<bdi
|
||||||
|
title={title}
|
||||||
|
className="line-clamp-2 min-w-0 font-bold leading-tight tracking-[-0.01em] text-content"
|
||||||
|
style={fs(13.5, 'body')}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</bdi>
|
||||||
|
{count !== undefined && (
|
||||||
|
<span
|
||||||
|
className="inline-flex h-[18px] flex-none items-center rounded-full bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary shadow-sm"
|
||||||
|
style={fs(10.5)}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{meta && <div className="mt-0.5 font-geist text-caption text-content-muted">{meta}</div>}
|
||||||
</div>
|
</div>
|
||||||
{meta && <div className="mt-0.5 text-caption text-content-muted">{meta}</div>}
|
{actions && <div className="flex flex-none items-center gap-1">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
{actions && <div className="flex flex-none items-center gap-1">{actions}</div>}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ export function CardRow({
|
|||||||
<span className="block text-body font-semibold leading-tight text-content">{title}</span>
|
<span className="block text-body font-semibold leading-tight text-content">{title}</span>
|
||||||
{facts}
|
{facts}
|
||||||
</span>
|
</span>
|
||||||
{aside && <span className="flex-none text-caption tabular-nums text-content-secondary">{aside}</span>}
|
{aside && (
|
||||||
|
<span className="flex flex-none items-center gap-2 text-caption tabular-nums text-content-secondary">
|
||||||
|
{aside}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
const hover = onHover
|
const hover = onHover
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Eyebrow, Field } from '../../../Planner/bookings/bookingParts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The body of an answer card in the trip dialogs' look: sections under an
|
||||||
|
* eyebrow of their own, figures as framed fields with their label over them
|
||||||
|
* (the field every booking card is made of), and a foot bar for the card's
|
||||||
|
* ways onward, set off by a line across the card.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One part of a card under its eyebrow, with room for something on the right of the label. */
|
||||||
|
export function CardSection({ label, aside, children }: { label: string; aside?: ReactNode; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="mt-3 first:mt-0">
|
||||||
|
<div className="mb-1.5 flex min-h-4 items-center gap-2">
|
||||||
|
<Eyebrow>{label}</Eyebrow>
|
||||||
|
{aside && <span className="ms-auto flex items-center gap-1">{aside}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A card's figures, side by side in framed fields: as many columns as figures, at most four. */
|
||||||
|
export function CardFields({ fields }: { fields: readonly { key: string; label: string; value: ReactNode }[] }) {
|
||||||
|
if (fields.length === 0) return null;
|
||||||
|
const columns = ['grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4'][Math.min(fields.length, 4) - 1];
|
||||||
|
return (
|
||||||
|
<div className={`grid gap-1.5 ${columns}`}>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div key={field.key} className="min-w-0" data-field={field.key}>
|
||||||
|
<Field label={field.label} tabular>
|
||||||
|
{field.value}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The foot of a card: its actions at the end, under a line that runs to the card's edges. */
|
||||||
|
export function CardFoot({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="-mx-3.5 -mb-3 mt-3 flex flex-wrap items-center justify-end gap-1.5 border-t border-edge-faint bg-surface-secondary px-3.5 py-2.5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ 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
|
||||||
@@ -13,7 +14,7 @@ import { formatDistance, formatElevation } from '../../../../utils/units';
|
|||||||
* 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 } = useTranslation();
|
const { locale, t } = 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(() => {
|
||||||
@@ -37,6 +38,8 @@ 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]);
|
}, [locale, twelveHour, unit, t]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Clock, MoveHorizontal, TrendingUp } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type { DistanceUnit } from '../../../../types';
|
||||||
|
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||||
|
import type { LegVariant } from '../../../Tours/planner/legVariants';
|
||||||
|
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A way's length, climb and time as one quiet line of glyphs and values,
|
||||||
|
* without a frame around each: what a choice of ways and a change row show
|
||||||
|
* where framed fields would crowd the row.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One figure of a way: its glyph and its value, quiet, in a line of them. */
|
||||||
|
function Figure({ name, icon, children }: { name: string; icon: ReactNode; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 whitespace-nowrap" data-figure={name}>
|
||||||
|
{icon}
|
||||||
|
<span className="tabular-nums">{children}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLYPH = { size: 11, strokeWidth: 2.2, 'aria-hidden': true, className: 'flex-none text-content-faint' } as const;
|
||||||
|
|
||||||
|
/** A way's length, climb and time in one quiet line. */
|
||||||
|
export default function WayFigures({ way, unit }: { way: LegVariant; unit: DistanceUnit }) {
|
||||||
|
return (
|
||||||
|
<span className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-caption text-content-secondary">
|
||||||
|
<Figure name="distance" icon={<MoveHorizontal {...GLYPH} />}>
|
||||||
|
{formatDistance(way.distanceM / 1000, unit)}
|
||||||
|
</Figure>
|
||||||
|
{way.gainM !== null && (
|
||||||
|
<Figure name="ascent" icon={<TrendingUp {...GLYPH} />}>
|
||||||
|
{formatElevation(way.gainM, unit)}
|
||||||
|
</Figure>
|
||||||
|
)}
|
||||||
|
{way.minutes !== null && (
|
||||||
|
<Figure name="time" icon={<Clock {...GLYPH} />}>
|
||||||
|
{formatTourDuration(way.minutes)}
|
||||||
|
</Figure>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useTranslation } from '../../../../i18n';
|
|||||||
import { SoftPill } from '../../../Planner/planParts';
|
import { SoftPill } from '../../../Planner/planParts';
|
||||||
import { useAssistantView } from '../../assistantViewContext';
|
import { useAssistantView } from '../../assistantViewContext';
|
||||||
import { CardOpenButton } from '../parts/CardOpen';
|
import { CardOpenButton } from '../parts/CardOpen';
|
||||||
|
import { CardFoot } from '../parts/CardSection';
|
||||||
import { tourTarget } from './tourLook';
|
import { tourTarget } from './tourLook';
|
||||||
import { useTakeProposal } from './useTourRouteCard';
|
import { useTakeProposal } from './useTourRouteCard';
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ export default function TourRouteActions({
|
|||||||
if (take.state === 'failed') caption = t('assistant.card.tour.askAgain');
|
if (take.state === 'failed') caption = t('assistant.card.tour.askAgain');
|
||||||
else if (take.state !== 'taken' && take.reasonKey) caption = t(take.reasonKey);
|
else if (take.state !== 'taken' && take.reasonKey) caption = t(take.reasonKey);
|
||||||
return (
|
return (
|
||||||
<div className="mt-2.5 flex flex-col items-end gap-1">
|
<CardFoot>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -92,10 +93,10 @@ export default function TourRouteActions({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{take.visible && caption && (
|
{take.visible && caption && (
|
||||||
<p className="m-0 text-end text-caption text-content-muted" role="status">
|
<p className="m-0 basis-full text-end text-caption text-content-muted" role="status">
|
||||||
{caption}
|
{caption}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardFoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042, FE-AI-TOUR-061 to FE-AI-TOUR-062
|
||||||
*
|
*
|
||||||
* 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
|
||||||
@@ -115,11 +115,11 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('TourRouteCard', () => {
|
describe('TourRouteCard', () => {
|
||||||
it('FE-AI-TOUR-021: states the name, the figures as badges, the day and the hardest grade, uncertain when little is graded', () => {
|
it('FE-AI-TOUR-021: states the name, the figures as framed fields, the day and the hardest grade, uncertain when little is graded', () => {
|
||||||
const { container } = renderCard(HIKE);
|
const { container } = renderCard(HIKE);
|
||||||
expect(screen.getByText('Hochfelln from Bergen')).toBeInTheDocument();
|
expect(screen.getByText('Hochfelln from Bergen')).toBeInTheDocument();
|
||||||
for (const figure of ['time', 'distance', 'ascent', 'descent']) {
|
for (const figure of ['time', 'distance', 'ascent', 'descent']) {
|
||||||
expect(container.querySelector(`[data-figure="${figure}"]`)).not.toBeNull();
|
expect(container.querySelector(`[data-field="${figure}"]`)).not.toBeNull();
|
||||||
}
|
}
|
||||||
expect(screen.getByText('8.2 km')).toBeInTheDocument();
|
expect(screen.getByText('8.2 km')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('640 m')).toHaveLength(2);
|
expect(screen.getAllByText('640 m')).toHaveLength(2);
|
||||||
@@ -159,7 +159,7 @@ describe('TourRouteCard', () => {
|
|||||||
|
|
||||||
it('FE-AI-TOUR-024: a stop chip moves the spot in focus to the stop while pointed at', () => {
|
it('FE-AI-TOUR-024: a stop chip moves the spot in focus to the stop while pointed at', () => {
|
||||||
renderCard(HIKE);
|
renderCard(HIKE);
|
||||||
const hut = screen.getByRole('button', { name: 'Strausshütte, 09:40' });
|
const hut = screen.getByRole('button', { name: /^Strausshütte, 09:40/ });
|
||||||
fireEvent.mouseEnter(hut);
|
fireEvent.mouseEnter(hut);
|
||||||
expect(useAssistantStore.getState().routeFocus?.focus.distanceMeters).toBeCloseTo(2400, 0);
|
expect(useAssistantStore.getState().routeFocus?.focus.distanceMeters).toBeCloseTo(2400, 0);
|
||||||
fireEvent.mouseLeave(hut);
|
fireEvent.mouseLeave(hut);
|
||||||
@@ -171,11 +171,11 @@ describe('TourRouteCard', () => {
|
|||||||
it('FE-AI-TOUR-024b: the stops run as a timeline, each walk on the line between two stops and read with the one it leads to', () => {
|
it('FE-AI-TOUR-024b: the stops run as a timeline, each walk on the line between two stops and read with the one it leads to', () => {
|
||||||
renderCard(HIKE);
|
renderCard(HIKE);
|
||||||
const list = screen.getByRole('list', { name: 'Stops along the way' });
|
const list = screen.getByRole('list', { name: 'Stops along the way' });
|
||||||
// One row per stop; the lines between them are not rows.
|
// One row per stop; the walks between them are hidden, read with the stop they lead to.
|
||||||
expect(list.querySelectorAll('[role="listitem"]')).toHaveLength(5);
|
expect(list.querySelectorAll('li:not([aria-hidden="true"])')).toHaveLength(5);
|
||||||
// The place beside the way has no time: the hut measures from the start, then each stop from the last.
|
// The place beside the way has no time: the hut measures from the start, then each stop from the last.
|
||||||
expect(screen.getAllByText(/from the last stop$/)).toHaveLength(3);
|
expect(screen.getAllByRole('button', { name: /from the last stop$/ })).toHaveLength(3);
|
||||||
const legs = [...list.querySelectorAll('[aria-hidden="true"]')].map((leg) => leg.textContent).filter(Boolean);
|
const legs = [...list.querySelectorAll('li[aria-hidden="true"]')].map((leg) => leg.textContent).filter(Boolean);
|
||||||
expect(legs).toHaveLength(3);
|
expect(legs).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,18 +234,21 @@ describe('TourRouteCard', () => {
|
|||||||
const decide = vi.fn(async (_key: number, decisions: { toolCallId: string; approved: boolean }[]) => {
|
const decide = vi.fn(async (_key: number, decisions: { toolCallId: string; approved: boolean }[]) => {
|
||||||
// The server applies the take; the thread shows it applied with the new tour's place.
|
// The server applies the take; the thread shows it applied with the new tour's place.
|
||||||
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
||||||
const applied = heldTake(true);
|
// The server sends the answer back with the take joined to it, now applied with the new tour's place.
|
||||||
applied.parts.push({
|
const applied = proposed();
|
||||||
|
applied.parts.push(...heldTake(true).parts, {
|
||||||
type: 'data-trek-applied',
|
type: 'data-trek-applied',
|
||||||
data: { toolCallId: decisions[0]?.toolCallId ?? '', entity: 'tour', ids: [77], dayId: 22, undoUntil: null },
|
data: { toolCallId: decisions[0]?.toolCallId ?? '', entity: 'tour', ids: [77], dayId: 22, undoUntil: null },
|
||||||
});
|
});
|
||||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [proposed(), applied] } } });
|
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [applied] } } });
|
||||||
});
|
});
|
||||||
seed([proposed()]);
|
seed([proposed()]);
|
||||||
useAssistantStore.setState({ decide });
|
useAssistantStore.setState({ decide });
|
||||||
takeProposal.mockImplementationOnce(async () => {
|
takeProposal.mockImplementationOnce(async () => {
|
||||||
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
||||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [proposed(), heldTake(undefined)] } } });
|
const joined = proposed();
|
||||||
|
joined.parts.push(...heldTake(undefined).parts);
|
||||||
|
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [joined] } } });
|
||||||
return 'held';
|
return 'held';
|
||||||
});
|
});
|
||||||
renderCard(HIKE, { onOpen });
|
renderCard(HIKE, { onOpen });
|
||||||
@@ -374,7 +377,7 @@ describe('TourRouteCard', () => {
|
|||||||
it('FE-AI-TOUR-042: the day shows the latest start when none is set, and the walk in the dark in red', () => {
|
it('FE-AI-TOUR-042: the day shows the latest start when none is set, and the walk in the dark in red', () => {
|
||||||
const schedule = HIKE.schedule as NonNullable<Route['schedule']>;
|
const schedule = HIKE.schedule as NonNullable<Route['schedule']>;
|
||||||
renderCard({ ...HIKE, schedule: { ...schedule, start: null, arrive: null, latestStart: '10:30' } });
|
renderCard({ ...HIKE, schedule: { ...schedule, start: null, arrive: null, latestStart: '10:30' } });
|
||||||
expect(screen.getByText('Start by 10:30 to finish before sunset')).toBeInTheDocument();
|
expect(screen.getByText('Start by 10:30')).toBeInTheDocument();
|
||||||
|
|
||||||
const { container } = renderCard({
|
const { container } = renderCard({
|
||||||
...HIKE,
|
...HIKE,
|
||||||
@@ -382,6 +385,46 @@ describe('TourRouteCard', () => {
|
|||||||
schedule: { ...schedule, start: '06:00', arrive: '19:00', latestStart: '10:30' },
|
schedule: { ...schedule, start: '06:00', arrive: '19:00', latestStart: '10:30' },
|
||||||
});
|
});
|
||||||
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 to finish before sunset')).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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { useMemo } from 'react';
|
|||||||
import { useTranslation } from '../../../../i18n';
|
import { useTranslation } from '../../../../i18n';
|
||||||
import type { DistanceIndexedProfileSample } from '../../../../utils/routeGeometry';
|
import type { DistanceIndexedProfileSample } from '../../../../utils/routeGeometry';
|
||||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||||
import { useAssistantView } from '../../assistantViewContext';
|
|
||||||
import CardFrame from '../parts/CardFrame';
|
import CardFrame from '../parts/CardFrame';
|
||||||
|
import { CardSection } from '../parts/CardSection';
|
||||||
import TourRouteActions from './TourRouteActions';
|
import TourRouteActions from './TourRouteActions';
|
||||||
import TourRouteDay from './TourRouteDay';
|
import TourRouteDay from './TourRouteDay';
|
||||||
import TourRouteHead from './TourRouteHead';
|
import TourRouteHead, { TourRouteFields } from './TourRouteHead';
|
||||||
import { profileSamples } from './tourRouteModel';
|
import { profileSamples } from './tourRouteModel';
|
||||||
import { TourRouteProblem, TourRouteWarnings } from './TourRouteNotes';
|
import { TourRouteProblem, TourRouteWarnings } from './TourRouteNotes';
|
||||||
import TourRouteProfile from './TourRouteProfile';
|
import TourRouteProfile from './TourRouteProfile';
|
||||||
@@ -40,19 +40,30 @@ export function TourRouteBody({
|
|||||||
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>;
|
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>;
|
||||||
letter?: string;
|
letter?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const samples = useMemo(() => profileSamples(card), [card]);
|
const samples = useMemo(() => profileSamples(card), [card]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TourRouteProfile
|
<TourRouteFields card={card} />
|
||||||
toolCallId={toolCallId}
|
{/* A way drawn straight has neither heights nor the line to say so under: no section at all. */}
|
||||||
card={card}
|
{(samples.length >= 2 || card.routed) && (
|
||||||
samples={samples}
|
<CardSection label={t('tours.planner.inspector.elevation')}>
|
||||||
color={color}
|
<TourRouteProfile
|
||||||
focus={route.focus}
|
toolCallId={toolCallId}
|
||||||
onFocus={route.setFocus}
|
card={card}
|
||||||
compare={compare}
|
samples={samples}
|
||||||
/>
|
color={color}
|
||||||
<TourRouteStops card={card} samples={samples} onFocus={route.setFocus} />
|
focus={route.focus}
|
||||||
|
onFocus={route.setFocus}
|
||||||
|
compare={compare}
|
||||||
|
/>
|
||||||
|
</CardSection>
|
||||||
|
)}
|
||||||
|
{card.stops.length > 0 && (
|
||||||
|
<CardSection label={t('assistant.card.tour.stops')}>
|
||||||
|
<TourRouteStops card={card} samples={samples} onFocus={route.setFocus} />
|
||||||
|
</CardSection>
|
||||||
|
)}
|
||||||
<TourRouteDay card={card} />
|
<TourRouteDay card={card} />
|
||||||
<TourRouteWarnings card={card} />
|
<TourRouteWarnings card={card} />
|
||||||
<TourRouteProblem card={card} />
|
<TourRouteProblem card={card} />
|
||||||
@@ -63,12 +74,11 @@ export function TourRouteBody({
|
|||||||
|
|
||||||
export default function TourRouteCard({ toolCallId, card }: { toolCallId: string; card: Route }) {
|
export default function TourRouteCard({ toolCallId, card }: { toolCallId: string; card: Route }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { density } = useAssistantView();
|
|
||||||
const route = useTourRouteCard(toolCallId, card);
|
const route = useTourRouteCard(toolCallId, card);
|
||||||
return (
|
return (
|
||||||
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||||
<CardFrame label={routeName(card, t)}>
|
<CardFrame label={routeName(card, t)}>
|
||||||
<TourRouteHead card={card} sketchHeight={density === 'roomy' ? 64 : 56} onShow={route.showOnMap} />
|
<TourRouteHead card={card} onShow={route.showOnMap} />
|
||||||
<TourRouteBody toolCallId={toolCallId} card={card} route={route} color={variantPaintCss('current')} />
|
<TourRouteBody toolCallId={toolCallId} card={card} route={route} color={variantPaintCss('current')} />
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createElement } from 'react';
|
|||||||
|
|
||||||
import { useTranslation } from '../../../../i18n';
|
import { useTranslation } from '../../../../i18n';
|
||||||
import { SoftPill } from '../../../Planner/planParts';
|
import { SoftPill } from '../../../Planner/planParts';
|
||||||
|
import { Tooltip } from '../../../shared/Tooltip';
|
||||||
import { weatherIconFor } from '../../../Weather/weatherIcons';
|
import { weatherIconFor } from '../../../Weather/weatherIcons';
|
||||||
import { useCardFormat } from '../parts/useCardFormat';
|
import { useCardFormat } from '../parts/useCardFormat';
|
||||||
import { type BarSpan, dayBarOf, forecastHours, RAIN_SHOWN_PCT } from './tourRouteDayModel';
|
import { type BarSpan, dayBarOf, forecastHours, RAIN_SHOWN_PCT } from './tourRouteDayModel';
|
||||||
@@ -93,9 +94,13 @@ export default function TourRouteDay({ card }: { card: TourRouteCard }) {
|
|||||||
)}
|
)}
|
||||||
{!schedule.start && schedule.latestStart && (
|
{!schedule.start && schedule.latestStart && (
|
||||||
<div className="mt-1 flex">
|
<div className="mt-1 flex">
|
||||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
<Tooltip label={t('assistant.card.tour.latestStart', { time: format.clock(schedule.latestStart) })}>
|
||||||
{t('assistant.card.tour.latestStart', { time: format.clock(schedule.latestStart) })}
|
<span className="inline-flex">
|
||||||
</SoftPill>
|
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||||
|
{t('assistant.card.tour.latestStartShort', { time: format.clock(schedule.latestStart) })}
|
||||||
|
</SoftPill>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{schedule.weather === 'climate' && (
|
{schedule.weather === 'climate' && (
|
||||||
|
|||||||
@@ -4,17 +4,19 @@ import { createElement } from 'react';
|
|||||||
|
|
||||||
import { useTranslation } from '../../../../i18n';
|
import { useTranslation } from '../../../../i18n';
|
||||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||||
|
import { fs, PILL } from '../../../shared/DialogShell';
|
||||||
import { Tooltip } from '../../../shared/Tooltip';
|
import { Tooltip } from '../../../shared/Tooltip';
|
||||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||||
import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
||||||
import { CardFacts } from '../parts/CardFacts';
|
import CardHeader from '../parts/CardHeader';
|
||||||
|
import { CardFields } from '../parts/CardSection';
|
||||||
import RouteSketch from '../parts/RouteSketch';
|
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 { routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
|
import { PROFILE_WORDS, 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
|
||||||
@@ -61,14 +63,16 @@ 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. Any
|
* means, the routing limit and where grades come from in its tooltip. A bike
|
||||||
* other tour says what kind it is instead.
|
* tour or a run names its profile instead, any other tour what kind it is.
|
||||||
*/
|
*/
|
||||||
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 });
|
||||||
return <SoftPill icon={icon}>{t(TOUR_TYPE_WORDS[card.activity].labelKey)}</SoftPill>;
|
// A bike tour or a run says what it was planned for: "Gravel", "Trail running".
|
||||||
|
const words = card.routingProfile ? PROFILE_WORDS[card.routingProfile] : TOUR_TYPE_WORDS[card.activity];
|
||||||
|
return <SoftPill icon={icon}>{t(words.labelKey)}</SoftPill>;
|
||||||
}
|
}
|
||||||
const { highest, unknownShare } = card.grades;
|
const { highest, unknownShare } = card.grades;
|
||||||
const uncertain = unknownShare >= 0.5;
|
const uncertain = unknownShare >= 0.5;
|
||||||
@@ -89,7 +93,7 @@ export function TourGrade({ card }: { card: TourRouteCard }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The day a route is planned on, as words: "Day 2, Tue 14 Oct", or the date alone. */
|
/** The day a route is planned on, as a white pill on the band: "Day 2, Tue 14 Oct", or the date alone. */
|
||||||
function DayPill({ card }: { card: TourRouteCard }) {
|
function DayPill({ card }: { card: TourRouteCard }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const format = useCardFormat();
|
const format = useCardFormat();
|
||||||
@@ -98,7 +102,12 @@ function DayPill({ card }: { card: TourRouteCard }) {
|
|||||||
const date = format.date(schedule.date);
|
const date = format.date(schedule.date);
|
||||||
const text =
|
const text =
|
||||||
schedule.dayNumber !== null ? `${t('assistant.card.tour.day', { day: schedule.dayNumber })}, ${date}` : date;
|
schedule.dayNumber !== null ? `${t('assistant.card.tour.day', { day: schedule.dayNumber })}, ${date}` : date;
|
||||||
return <SoftPill icon={<CalendarDays size={10} strokeWidth={2.2} aria-hidden="true" />}>{text}</SoftPill>;
|
return (
|
||||||
|
<span className={PILL} style={fs(11)}>
|
||||||
|
<CalendarDays size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The route drawn small: a button that shows it on the map. */
|
/** The route drawn small: a button that shows it on the map. */
|
||||||
@@ -127,35 +136,55 @@ export function TourRouteSketch({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TourRouteHead({
|
/** The route's figures as framed fields, each under its label: distance, climb, descent and time, as known. */
|
||||||
card,
|
export function TourRouteFields({ card }: { card: TourRouteCard }) {
|
||||||
sketchHeight,
|
const { t } = useTranslation();
|
||||||
onShow,
|
const format = useCardFormat();
|
||||||
}: {
|
const fields: Array<{ key: string; label: string; value: string }> = factKeysOf(card).flatMap((key) => {
|
||||||
card: TourRouteCard;
|
const value = factValue(card, key);
|
||||||
sketchHeight: number;
|
if (value === null) return [];
|
||||||
onShow: () => void;
|
// A field's label is short; how the time was reckoned is the figure badges' tooltip.
|
||||||
}) {
|
const label = t(FACT_LOOK[key].labelKey ?? 'tours.planner.inspector.duration');
|
||||||
|
let text: string;
|
||||||
|
if (key === 'time') text = formatTourDuration(value);
|
||||||
|
else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
|
||||||
|
return [{ key, label, value: text }];
|
||||||
|
});
|
||||||
|
// A run's time is its distance at the runner's pace: the pace stands beside it.
|
||||||
|
if (card.paceSecondsPerKm != null) {
|
||||||
|
fields.push({ key: 'pace', label: t('tours.run.pace'), value: format.pace(card.paceSecondsPerKm) });
|
||||||
|
}
|
||||||
|
return <CardFields fields={fields} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The head band of a route card: the route drawn small on a raised tile, what
|
||||||
|
* kind of tour it is above its name, the label under it, and its grade and
|
||||||
|
* the day it is planned on as pills on the band.
|
||||||
|
*/
|
||||||
|
export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; onShow: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const name = routeName(card, t);
|
const name = routeName(card, t);
|
||||||
|
const tile = (
|
||||||
|
<span className="w-12 flex-none overflow-hidden rounded-[10px] bg-surface-card p-1 shadow-sm">
|
||||||
|
<TourRouteSketch card={card} height={48} onShow={onShow} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2.5">
|
<CardHeader
|
||||||
<div className="w-[72px] flex-none">
|
icon={tourDayRowIcon(card.activity)}
|
||||||
<TourRouteSketch card={card} height={sketchHeight} onShow={onShow} />
|
tone="info"
|
||||||
</div>
|
tile={tile}
|
||||||
<div className="min-w-0 flex-1">
|
eyebrow={t(TOUR_TYPE_WORDS[card.activity].labelKey)}
|
||||||
<div className="flex min-w-0 items-start gap-2">
|
title={name}
|
||||||
<bdi title={name} className="line-clamp-2 min-w-0 flex-1 text-body font-semibold leading-tight text-content">
|
meta={card.label ?? undefined}
|
||||||
{name}
|
>
|
||||||
</bdi>
|
{((card.activity === 'hike' && card.grades) || card.routingProfile || card.schedule) && (
|
||||||
<TourGrade card={card} />
|
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||||
</div>
|
{((card.activity === 'hike' && card.grades) || card.routingProfile) && <TourGrade card={card} />}
|
||||||
{card.label && <p className="m-0 mt-0.5 truncate text-caption text-content-muted">{card.label}</p>}
|
|
||||||
<CardFacts>
|
|
||||||
<TourRouteFigures card={card} />
|
|
||||||
<DayPill card={card} />
|
<DayPill card={card} />
|
||||||
</CardFacts>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</CardHeader>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* FE-AI-TOUR-033 to FE-AI-TOUR-034
|
* FE-AI-TOUR-033 to FE-AI-TOUR-034, FE-AI-TOUR-063
|
||||||
*
|
*
|
||||||
* 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,4 +71,10 @@ 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
|||||||
import type { ChangeItem } from '../../assistantParts';
|
import type { ChangeItem } from '../../assistantParts';
|
||||||
import { verbLookOf } from '../cardLooks';
|
import { verbLookOf } from '../cardLooks';
|
||||||
import KindTile from '../parts/KindTile';
|
import KindTile from '../parts/KindTile';
|
||||||
import { TourGrade, TourRouteFigures, TourRouteSketch } from './TourRouteHead';
|
import WayFigures from './TourFigureLine';
|
||||||
|
import { TourGrade, TourRouteSketch } from './TourRouteHead';
|
||||||
import { legVariantOf, previousVariantOf, profileBands, profileSamples } from './tourRouteModel';
|
import { legVariantOf, previousVariantOf, profileBands, profileSamples } from './tourRouteModel';
|
||||||
import { useTourRouteCard } from './useTourRouteCard';
|
import { useTourRouteCard } from './useTourRouteCard';
|
||||||
|
|
||||||
@@ -37,29 +38,33 @@ export default function TourRouteInRow({ card, item }: { card: TourRouteCard; it
|
|||||||
const samples = useMemo(() => profileSamples(card), [card]);
|
const samples = useMemo(() => profileSamples(card), [card]);
|
||||||
const previous = previousVariantOf(card);
|
const previous = previousVariantOf(card);
|
||||||
const notPlanned = card.problem?.why === 'not_planned';
|
const notPlanned = card.problem?.why === 'not_planned';
|
||||||
|
const grades = card.activity === 'hike' ? card.grades : null;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5" data-testid="tour-route-row">
|
<div
|
||||||
<div className="flex items-start gap-2">
|
className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-secondary p-2.5"
|
||||||
<div className="w-16 flex-none">
|
data-testid="tour-route-row"
|
||||||
<TourRouteSketch card={card} height={40} onShow={showOnMap} />
|
>
|
||||||
</div>
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="min-w-0 flex-1">
|
<span className="w-14 flex-none overflow-hidden rounded-[10px] bg-surface-card p-1 shadow-sm">
|
||||||
<span className="flex flex-wrap items-center gap-1">
|
<TourRouteSketch card={card} height={36} onShow={showOnMap} />
|
||||||
<TourRouteFigures card={card} />
|
</span>
|
||||||
<TourGrade card={card} />
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
{previous && (
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<VariantDelta
|
<span className="min-w-0 flex-1">
|
||||||
variant={legVariantOf(card, 'current')}
|
<WayFigures way={legVariantOf(card, 'current')} unit={unit} />
|
||||||
current={previous}
|
</span>
|
||||||
unit={unit}
|
{((grades && grades.highest !== null) || card.routingProfile) && <TourGrade card={card} />}
|
||||||
t={t}
|
|
||||||
hint={t('assistant.card.tour.deltaBefore')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{notPlanned && (
|
{previous && (
|
||||||
<p className="m-0 mt-1 text-caption text-content-faint">{t('assistant.card.tour.notPlanned')}</p>
|
<VariantDelta
|
||||||
|
variant={legVariantOf(card, 'current')}
|
||||||
|
current={previous}
|
||||||
|
unit={unit}
|
||||||
|
t={t}
|
||||||
|
hint={t('assistant.card.tour.deltaBefore')}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
{notPlanned && <p className="m-0 text-caption text-content-faint">{t('assistant.card.tour.notPlanned')}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!notPlanned && samples.length >= 2 && (
|
{!notPlanned && samples.length >= 2 && (
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function TourRouteProfile({ toolCallId, card, samples, color, foc
|
|||||||
const bands = useMemo(() => profileBands(card, samples), [card, samples]);
|
const bands = useMemo(() => profileBands(card, samples), [card, samples]);
|
||||||
if (samples.length < 2) {
|
if (samples.length < 2) {
|
||||||
if (!card.routed) return null;
|
if (!card.routed) return null;
|
||||||
return <p className="m-0 mt-2 text-caption text-content-faint">{t('assistant.card.tour.noHeights')}</p>;
|
return <p className="m-0 text-caption text-content-faint">{t('assistant.card.tour.noHeights')}</p>;
|
||||||
}
|
}
|
||||||
const phone = variant === 'fullscreen';
|
const phone = variant === 'fullscreen';
|
||||||
const focusGrade = focus ? gradeAt(grades, focus.distanceMeters) : null;
|
const focusGrade = focus ? gradeAt(grades, focus.distanceMeters) : null;
|
||||||
@@ -112,7 +112,7 @@ export default function TourRouteProfile({ toolCallId, card, samples, color, foc
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2">
|
<div className="rounded-[12px] border border-edge-faint bg-surface-secondary px-2 pb-1.5 pt-2">
|
||||||
<ElevationProfile
|
<ElevationProfile
|
||||||
samples={samples}
|
samples={samples}
|
||||||
color={color}
|
color={color}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { TourRouteCard, TourRouteStop } from '@trek/shared';
|
import type { TourRouteCard, TourRouteStop } from '@trek/shared';
|
||||||
import { AlertTriangle, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
|
import { AlertTriangle, Clock, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
|
||||||
import { createElement, type ReactNode } from 'react';
|
import { createElement, type ReactNode } from 'react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../../i18n';
|
import { useTranslation } from '../../../../i18n';
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type RouteProfileFocus,
|
type RouteProfileFocus,
|
||||||
} from '../../../../utils/routeGeometry';
|
} from '../../../../utils/routeGeometry';
|
||||||
import { TOUR_POI_LOOKS } from '../../../Map/poiCategories';
|
import { TOUR_POI_LOOKS } from '../../../Map/poiCategories';
|
||||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
import { SoftPill } from '../../../Planner/planParts';
|
||||||
import { Tooltip } from '../../../shared/Tooltip';
|
import { Tooltip } from '../../../shared/Tooltip';
|
||||||
import { StageEndCorner } from '../../../Tours/stages/StageParts';
|
import { StageEndCorner } from '../../../Tours/stages/StageParts';
|
||||||
import { useCardFormat } from '../parts/useCardFormat';
|
import { useCardFormat } from '../parts/useCardFormat';
|
||||||
@@ -18,12 +18,13 @@ import { FLAG_LOOK, stopName } from './tourRouteWords';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The stops of a route as a timeline in the order they lie along it: one row
|
* The stops of a route as a timeline in the order they lie along it: one row
|
||||||
* per stop (the start and the end, the huts, summits and stations on the way
|
* per stop on a line down the card (the start and the end filled, the huts,
|
||||||
* with their clock, what lies a little off the way quieter), joined by a line
|
* summits and stations on the way with their glyph, what lies a little off
|
||||||
* that carries the walking time of the stretch between two stops, so a time
|
* the way quieter), its name, how far off the way or how long the stay under
|
||||||
* is never read as belonging to the stop beside it. Pointing at a chip or
|
* it, and its clock at the end; the walk between two stops sits on the line
|
||||||
* tapping it moves the spot in focus there, on the profile and on the map
|
* between their rows, so a time is never read as a stop's own. Pointing at a
|
||||||
* alike. Past eight the middle ones fold into a count that names them.
|
* row or tapping it moves the spot in focus there, on the profile and on the
|
||||||
|
* map alike. Past eight the middle ones fold into a row that names them.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A stop's glyph: the flag at either end, the mountain at the highest point, the place's own for a kind. */
|
/** A stop's glyph: the flag at either end, the mountain at the highest point, the place's own for a kind. */
|
||||||
@@ -46,12 +47,31 @@ function focusOf(stop: TourRouteStop, samples: readonly DistanceIndexedProfileSa
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StopChip({
|
/** A stop's mark on the line: filled at the start and the end, a ring with its glyph on the way, smaller off it. */
|
||||||
|
function Marker({ stop }: { stop: TourRouteStop }) {
|
||||||
|
const end = stop.role === 'start' || stop.role === 'end';
|
||||||
|
const along = stop.role === 'along';
|
||||||
|
const glyph = createElement(iconOf(stop), { size: along ? 9 : 10, strokeWidth: 2.2, 'aria-hidden': true });
|
||||||
|
let look = 'h-5 w-5 bg-surface-card text-content-muted ring-1 ring-inset ring-edge';
|
||||||
|
if (end) look = 'h-5 w-5 bg-accent text-accent-text';
|
||||||
|
else if (along) look = 'h-4 w-4 bg-surface-secondary text-content-faint ring-1 ring-inset ring-edge-faint';
|
||||||
|
return (
|
||||||
|
<span className="relative z-[1] flex w-5 flex-none justify-center">
|
||||||
|
<span className={`grid place-items-center rounded-full ${look}`}>{glyph}</span>
|
||||||
|
{stop.stageEnd && <StageEndCorner />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StopRow({
|
||||||
stop,
|
stop,
|
||||||
|
leg,
|
||||||
samples,
|
samples,
|
||||||
onFocus,
|
onFocus,
|
||||||
}: {
|
}: {
|
||||||
stop: TourRouteStop;
|
stop: TourRouteStop;
|
||||||
|
/** The walk from the last stop with a time to this one, read with it. */
|
||||||
|
leg: number | null;
|
||||||
samples: readonly DistanceIndexedProfileSample[];
|
samples: readonly DistanceIndexedProfileSample[];
|
||||||
onFocus: (focus: RouteProfileFocus | null) => void;
|
onFocus: (focus: RouteProfileFocus | null) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -65,44 +85,45 @@ function StopChip({
|
|||||||
: null;
|
: null;
|
||||||
const stay = stop.stayMinutes ? t('assistant.card.tour.stay', { time: format.minutes(stop.stayMinutes) }) : null;
|
const stay = stop.stayMinutes ? t('assistant.card.tour.stay', { time: format.minutes(stop.stayMinutes) }) : null;
|
||||||
const clock = stop.clock ? format.clock(stop.clock) : null;
|
const clock = stop.clock ? format.clock(stop.clock) : null;
|
||||||
const label = [name, clock, off, stay].filter(Boolean).join(', ');
|
const walked = leg !== null ? t('assistant.card.tour.legTime', { time: format.minutes(leg) }) : null;
|
||||||
const glyph = createElement(iconOf(stop), {
|
const label = [name, clock, off, stay, walked].filter(Boolean).join(', ');
|
||||||
size: 10,
|
const point = () => onFocus(focusOf(stop, samples));
|
||||||
strokeWidth: 2.2,
|
const under = [off, stay].filter((part): part is string => part !== null);
|
||||||
'aria-hidden': true,
|
|
||||||
className: 'flex-none text-content-faint',
|
|
||||||
});
|
|
||||||
const body = (
|
|
||||||
<>
|
|
||||||
<bdi className="max-w-[10rem] truncate">{name}</bdi>
|
|
||||||
{clock && <span className="tabular-nums text-content-muted">{clock}</span>}
|
|
||||||
{off && <span className="text-content-faint">{off}</span>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1">
|
<li className="flex">
|
||||||
<Tooltip label={label}>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
aria-label={label}
|
||||||
aria-label={label}
|
onMouseEnter={point}
|
||||||
onMouseEnter={() => onFocus(focusOf(stop, samples))}
|
onMouseLeave={() => onFocus(null)}
|
||||||
onMouseLeave={() => onFocus(null)}
|
onFocus={point}
|
||||||
onFocus={() => onFocus(focusOf(stop, samples))}
|
onBlur={() => onFocus(null)}
|
||||||
onBlur={() => onFocus(null)}
|
onClick={point}
|
||||||
onClick={() => onFocus(focusOf(stop, samples))}
|
className="-mx-1.5 flex min-w-0 flex-1 items-center gap-2.5 rounded-[10px] px-1.5 py-1 text-start transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
className="relative inline-flex rounded-full focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
>
|
||||||
>
|
<Marker stop={stop} />
|
||||||
{along ? <WhiteBadge icon={glyph}>{body}</WhiteBadge> : <SoftPill icon={glyph}>{body}</SoftPill>}
|
<span className="min-w-0 flex-1">
|
||||||
{stop.stageEnd && <StageEndCorner />}
|
<bdi
|
||||||
</button>
|
className={`block truncate leading-tight ${along ? 'text-caption text-content-secondary' : 'text-body font-medium text-content'}`}
|
||||||
</Tooltip>
|
>
|
||||||
{stay && <span className="text-caption text-content-faint">{stay}</span>}
|
{name}
|
||||||
{stop.flag && (
|
</bdi>
|
||||||
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
{(under.length > 0 || stop.flag) && (
|
||||||
{t(FLAG_LOOK[stop.flag].labelKey)}
|
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-caption text-content-faint">
|
||||||
</SoftPill>
|
{under.map((part) => (
|
||||||
)}
|
<span key={part}>{part}</span>
|
||||||
</span>
|
))}
|
||||||
|
{stop.flag && (
|
||||||
|
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||||
|
{t(FLAG_LOOK[stop.flag].labelKey)}
|
||||||
|
</SoftPill>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{clock && <span className="flex-none text-caption tabular-nums text-content-muted">{clock}</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,38 +133,36 @@ function legMinutes(from: TourRouteStop | undefined, to: TourRouteStop): number
|
|||||||
return to.minutes - from.minutes;
|
return to.minutes - from.minutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The line between two rows, with the stretch's walking time on it. */
|
/** The walk between two rows, beside the line: read with the row it leads to, so hidden here. */
|
||||||
function Leg({ minutes }: { minutes: number | null }) {
|
function Leg({ minutes }: { minutes: number | null }) {
|
||||||
const format = useCardFormat();
|
const format = useCardFormat();
|
||||||
|
if (minutes === null) return <li aria-hidden="true" className="h-1.5" />;
|
||||||
return (
|
return (
|
||||||
// Read with the stop it leads to; a list holds only its rows.
|
<li aria-hidden="true" className="flex items-center py-0.5 ps-[30px]">
|
||||||
<span aria-hidden="true" className="flex min-h-[14px] items-stretch gap-2">
|
<span className="inline-flex items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-caption tabular-nums text-content-muted">
|
||||||
<span className="flex w-2.5 flex-none justify-center">
|
<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />
|
||||||
<span className="w-px bg-edge" />
|
{format.minutes(minutes)}
|
||||||
</span>
|
</span>
|
||||||
{minutes !== null && (
|
</li>
|
||||||
<span className="text-caption tabular-nums leading-tight text-content-faint">{format.minutes(minutes)}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One row of the timeline: its dot on the line, then the stop, read after the walk that leads to it. */
|
/** The middle stops folded into one row past eight, naming them in its tooltip. */
|
||||||
function Row({ end, leg, children }: { end: boolean; leg?: number | null; children: ReactNode }) {
|
function FoldedRow({ stops }: { stops: readonly TourRouteStop[] }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const format = useCardFormat();
|
const names = stops.map((stop) => stopName(stop, t)).join(', ');
|
||||||
return (
|
return (
|
||||||
<span role="listitem" className="flex items-center gap-2">
|
<li className="flex items-center gap-2.5 py-1">
|
||||||
{leg != null && (
|
<span className="relative z-[1] flex w-5 flex-none justify-center">
|
||||||
<span className="sr-only">{t('assistant.card.tour.legTime', { time: format.minutes(leg) })}</span>
|
<span className="h-2 w-2 rounded-full border border-dashed border-content-faint bg-surface-card" />
|
||||||
)}
|
|
||||||
<span className="flex w-2.5 flex-none justify-center">
|
|
||||||
<span
|
|
||||||
className={`h-2 w-2 rounded-full ${end ? 'bg-content-muted' : 'border border-content-faint bg-surface-card'}`}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">{children}</span>
|
<Tooltip label={names}>
|
||||||
</span>
|
<span className="text-caption text-content-muted underline decoration-dotted underline-offset-2">
|
||||||
|
{t('assistant.card.tour.stopsMore', { count: stops.length })}
|
||||||
|
<span className="sr-only">{`: ${names}`}</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,42 +178,30 @@ export default function TourRouteStops({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { head, folded, tail } = chipStops(card);
|
const { head, folded, tail } = chipStops(card);
|
||||||
if (head.length === 0) return null;
|
if (head.length === 0) return null;
|
||||||
const names = folded.map((stop) => stopName(stop, t)).join(', ');
|
|
||||||
// The time is walked from the last stop that has one: a place beside the way has none.
|
// The time is walked from the last stop that has one: a place beside the way has none.
|
||||||
let walked: TourRouteStop | undefined;
|
let walked: TourRouteStop | undefined;
|
||||||
const rows: ReactNode[] = [];
|
const rows: ReactNode[] = [];
|
||||||
const pushStop = (stop: TourRouteStop, key: string) => {
|
const pushStop = (stop: TourRouteStop, key: string) => {
|
||||||
const leg = legMinutes(walked, stop);
|
const leg = legMinutes(walked, stop);
|
||||||
if (rows.length > 0) rows.push(<Leg key={`${key}-leg`} minutes={leg} />);
|
if (rows.length > 0) rows.push(<Leg key={`${key}-leg`} minutes={leg} />);
|
||||||
rows.push(
|
rows.push(<StopRow key={key} stop={stop} leg={leg} samples={samples} onFocus={onFocus} />);
|
||||||
<Row key={key} end={stop.role === 'start' || stop.role === 'end'} leg={leg}>
|
|
||||||
<StopChip stop={stop} samples={samples} onFocus={onFocus} />
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
if (stop.minutes !== null) walked = stop;
|
if (stop.minutes !== null) walked = stop;
|
||||||
};
|
};
|
||||||
head.forEach((stop, index) => pushStop(stop, `h-${index}`));
|
head.forEach((stop, index) => pushStop(stop, `h-${index}`));
|
||||||
if (folded.length > 0) {
|
if (folded.length > 0) {
|
||||||
rows.push(<Leg key="f-leg" minutes={null} />);
|
rows.push(<Leg key="f-leg" minutes={null} />);
|
||||||
rows.push(
|
rows.push(<FoldedRow key="f" stops={folded} />);
|
||||||
<Row key="f" end={false}>
|
|
||||||
<Tooltip label={names}>
|
|
||||||
<span className="inline-flex">
|
|
||||||
<WhiteBadge>
|
|
||||||
{t('assistant.card.tour.stopsMore', { count: folded.length })}
|
|
||||||
<span className="sr-only">{`: ${names}`}</span>
|
|
||||||
</WhiteBadge>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
// The folded stretch's time is not one leg; the next stop measures from the last folded one.
|
// The folded stretch's time is not one leg; the next stop measures from the last folded one.
|
||||||
walked = [...folded].reverse().find((stop) => stop.minutes !== null) ?? walked;
|
walked = [...folded].reverse().find((stop) => stop.minutes !== null) ?? walked;
|
||||||
}
|
}
|
||||||
tail.forEach((stop, index) => pushStop(stop, `t-${index}`));
|
tail.forEach((stop, index) => pushStop(stop, `t-${index}`));
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 flex flex-col" role="list" aria-label={t('assistant.card.tour.stops')}>
|
<div className="relative">
|
||||||
{rows}
|
{/* The line down the timeline, from the first mark to the last, under the marks. */}
|
||||||
|
<span aria-hidden="true" className="absolute bottom-3 start-[9.5px] top-3 w-px bg-edge" />
|
||||||
|
<ul className="relative m-0 flex list-none flex-col p-0" aria-label={t('assistant.card.tour.stops')}>
|
||||||
|
{rows}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,131 @@
|
|||||||
|
import type { TourRouteCard as Route } from '@trek/shared';
|
||||||
|
import { Route as RouteIcon } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../../i18n';
|
import { useTranslation } from '../../../../i18n';
|
||||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||||
|
import type { DistanceUnit } from '../../../../types';
|
||||||
|
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||||
import { VariantDelta } from '../../../Tours/planner/LegVariantsCard';
|
import { type LegVariant, variantDelta } from '../../../Tours/planner/legVariants';
|
||||||
import { VariantFigures, WayChoice } from '../../../Tours/planner/mapCardParts';
|
import { VariantMark } from '../../../Tours/planner/mapCardParts';
|
||||||
|
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||||
import { useAssistantView } from '../../assistantViewContext';
|
import { useAssistantView } from '../../assistantViewContext';
|
||||||
import CardFrame from '../parts/CardFrame';
|
import CardFrame from '../parts/CardFrame';
|
||||||
|
import CardHeader from '../parts/CardHeader';
|
||||||
|
import { CardSection } from '../parts/CardSection';
|
||||||
|
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 { useLineHover, useTourRouteCard } from './useTourRouteCard';
|
import { useLineHover, useTourRouteCard } from './useTourRouteCard';
|
||||||
|
|
||||||
/** The ways of one answer with the chosen one known: its choices, then the chosen way's body. */
|
/** A signed difference: a plus for more, a minus sign for less, nothing for none worth printing. */
|
||||||
|
function signed(value: number, format: (absolute: number) => string): string | null {
|
||||||
|
const text = format(Math.abs(value));
|
||||||
|
if (Math.abs(value) < 1e-9 || /^0\s/.test(text)) return null;
|
||||||
|
return `${value > 0 ? '+' : '−'}${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a way differs from the chosen one, as one faint line: "Compared with way A: −4.3 km −178 m −1 h 33 min". */
|
||||||
|
function WayDelta({
|
||||||
|
way,
|
||||||
|
chosen,
|
||||||
|
letter,
|
||||||
|
unit,
|
||||||
|
}: {
|
||||||
|
way: LegVariant;
|
||||||
|
chosen: LegVariant;
|
||||||
|
letter: string;
|
||||||
|
unit: DistanceUnit;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const delta = variantDelta(way, chosen);
|
||||||
|
const parts = [
|
||||||
|
signed(delta.distanceM / 1000, (km) => formatDistance(km, unit)),
|
||||||
|
delta.gainM === null ? null : signed(delta.gainM, (m) => formatElevation(m, unit)),
|
||||||
|
delta.minutes === null ? null : signed(delta.minutes, formatTourDuration),
|
||||||
|
].filter((part): part is string => part !== null);
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex flex-wrap items-center gap-x-2 text-caption text-content-faint"
|
||||||
|
data-testid="leg-variant-delta"
|
||||||
|
>
|
||||||
|
<span>{t('assistant.card.tour.deltaTo', { letter })}</span>
|
||||||
|
{parts.map((part) => (
|
||||||
|
<span key={part} className="tabular-nums text-content-muted">
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One way to choose: its letter in the colour it is drawn in, its name and grade, its figures, how it differs. */
|
||||||
|
function WayOption({
|
||||||
|
entry,
|
||||||
|
index,
|
||||||
|
chosen,
|
||||||
|
chosenWay,
|
||||||
|
chosenLetter,
|
||||||
|
unit,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
}: {
|
||||||
|
entry: RouteEntry;
|
||||||
|
index: number;
|
||||||
|
chosen: boolean;
|
||||||
|
chosenWay: LegVariant;
|
||||||
|
chosenLetter: string;
|
||||||
|
unit: DistanceUnit;
|
||||||
|
onSelect: () => void;
|
||||||
|
onHover: (on: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const key = WAY_KEYS[index] ?? 'a';
|
||||||
|
const letter = key.toUpperCase();
|
||||||
|
const way = legVariantOf(entry.card, key);
|
||||||
|
const grades = entry.card.grades;
|
||||||
|
const name = entry.card.label ?? t('tours.variants.option', { letter });
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={chosen}
|
||||||
|
aria-label={name}
|
||||||
|
onClick={onSelect}
|
||||||
|
onMouseEnter={() => onHover(true)}
|
||||||
|
onMouseLeave={() => onHover(false)}
|
||||||
|
onFocus={() => onHover(true)}
|
||||||
|
onBlur={() => onHover(false)}
|
||||||
|
className={`flex w-full items-start gap-2.5 rounded-[12px] border px-2.5 py-2 text-start transition-colors ${
|
||||||
|
chosen
|
||||||
|
? 'border-accent bg-surface-card shadow-sm'
|
||||||
|
: 'border-edge-faint bg-surface-secondary hover:border-edge hover:bg-surface-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<VariantMark variant={way} chosen={chosen} />
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<bdi className="min-w-0 flex-1 truncate text-body font-semibold leading-tight text-content">{name}</bdi>
|
||||||
|
{grades && grades.highest !== null && (
|
||||||
|
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />
|
||||||
|
)}
|
||||||
|
{/* Ways of a ride or a run may differ by profile: each says its own. */}
|
||||||
|
{entry.card.routingProfile && <TourGrade card={entry.card} />}
|
||||||
|
</span>
|
||||||
|
<WayFigures way={way} unit={unit} />
|
||||||
|
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ways of one answer with the chosen one known: the band, the choices, then the chosen way's body. */
|
||||||
function WaysCard({
|
function WaysCard({
|
||||||
entries,
|
entries,
|
||||||
chosen,
|
chosen,
|
||||||
@@ -44,57 +156,49 @@ function WaysCard({
|
|||||||
[entries, chosenIndex]
|
[entries, chosenIndex]
|
||||||
);
|
);
|
||||||
|
|
||||||
const current = legVariantOf(chosen.card, chosenKey);
|
const chosenWay = legVariantOf(chosen.card, chosenKey);
|
||||||
// Side by side where the window is roomy; on the phone the ways stay rows, so a finger scrubs the profile freely.
|
// Side by side where the window is roomy; on the phone the ways stay rows, so a finger scrubs the profile freely.
|
||||||
let columns = '';
|
let columns = '';
|
||||||
if (density === 'roomy' && variant !== 'fullscreen') columns = entries.length >= 3 ? 'grid-cols-3' : 'grid-cols-2';
|
if (density === 'roomy' && variant !== 'fullscreen') columns = entries.length >= 3 ? 'grid-cols-3' : 'grid-cols-2';
|
||||||
const title = t('assistant.card.tour.ways', { count: entries.length });
|
const title = t('assistant.card.tour.ways', { count: entries.length });
|
||||||
|
const card: Route = chosen.card;
|
||||||
return (
|
return (
|
||||||
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
<div {...route.handlers} className={`rounded-[16px] ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||||
<CardFrame label={title}>
|
<CardFrame label={title}>
|
||||||
<p className="m-0 text-caption text-content-muted">{title}</p>
|
<CardHeader
|
||||||
<div role="radiogroup" aria-label={title} className={`mt-1.5 grid gap-1.5 ${columns}`}>
|
icon={RouteIcon}
|
||||||
{entries.map((entry, index) => {
|
tone="info"
|
||||||
const key = WAY_KEYS[index] ?? 'a';
|
eyebrow={t(TOUR_TYPE_WORDS[card.activity].labelKey)}
|
||||||
const letter = key.toUpperCase();
|
title={routeName(card, t)}
|
||||||
const way = legVariantOf(entry.card, key);
|
meta={title}
|
||||||
const grades = entry.card.grades;
|
/>
|
||||||
return (
|
<CardSection label={t('assistant.card.tour.waysPick')}>
|
||||||
<WayChoice
|
<div role="radiogroup" aria-label={title} className={`grid gap-1.5 ${columns}`}>
|
||||||
|
{entries.map((entry, index) => (
|
||||||
|
<WayOption
|
||||||
key={entry.toolCallId}
|
key={entry.toolCallId}
|
||||||
way={way}
|
entry={entry}
|
||||||
name={entry.card.label ?? t('tours.variants.option', { letter })}
|
index={index}
|
||||||
chosen={index === chosenIndex}
|
chosen={index === chosenIndex}
|
||||||
aside={
|
chosenWay={chosenWay}
|
||||||
<>
|
chosenLetter={chosenKey.toUpperCase()}
|
||||||
{index !== chosenIndex && (
|
unit={unit}
|
||||||
<VariantDelta
|
|
||||||
variant={way}
|
|
||||||
current={current}
|
|
||||||
unit={unit}
|
|
||||||
t={t}
|
|
||||||
hint={t('assistant.card.tour.deltaTo', { letter: chosenKey.toUpperCase() })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{grades && <SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onSelect={() => pick(tripId, blockKey, entry.toolCallId)}
|
onSelect={() => pick(tripId, blockKey, entry.toolCallId)}
|
||||||
onHover={(on) => hover(entry.toolCallId, on)}
|
onHover={(on) => hover(entry.toolCallId, on)}
|
||||||
>
|
/>
|
||||||
<VariantFigures variant={way} unit={unit} t={t} />
|
))}
|
||||||
</WayChoice>
|
</div>
|
||||||
);
|
</CardSection>
|
||||||
})}
|
<div className="mt-3">
|
||||||
|
<TourRouteBody
|
||||||
|
toolCallId={chosen.toolCallId}
|
||||||
|
card={chosen.card}
|
||||||
|
route={route}
|
||||||
|
color={variantPaintCss(chosenKey)}
|
||||||
|
compare={compare}
|
||||||
|
letter={chosenKey.toUpperCase()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<TourRouteBody
|
|
||||||
toolCallId={chosen.toolCallId}
|
|
||||||
card={chosen.card}
|
|
||||||
route={route}
|
|
||||||
color={variantPaintCss(chosenKey)}
|
|
||||||
compare={compare}
|
|
||||||
letter={chosenKey.toUpperCase()}
|
|
||||||
/>
|
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -102,12 +206,12 @@ function WaysCard({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Two or three ways of one route an answer proposed, as the planner offers a
|
* Two or three ways of one route an answer proposed, as the planner offers a
|
||||||
* section's ways: each a choice with its letter in the colour it is drawn in,
|
* section's ways: a band naming the route, each way a choice with its letter
|
||||||
* its own words, how it differs from the chosen one and its hardest grade,
|
* in the colour it is drawn in, its name and hardest grade, its figures and
|
||||||
* its figures below. One profile holds them all, the chosen way filled and
|
* how it differs from the chosen one; one profile holds them all, the chosen
|
||||||
* the others thin on the same axes; under it the chosen way's stops, day and
|
* way filled and the others thin on the same axes; under it the chosen way's
|
||||||
* warnings, and "Plan way B". A choice is kept per answer, and pointing at a
|
* figures, stops, day and warnings, and "Plan way B". A choice is kept per
|
||||||
* way lights its line on the map.
|
* answer, and pointing at a way lights its line on the map.
|
||||||
*/
|
*/
|
||||||
export default function TourWays({ entries }: { entries: readonly RouteEntry[] }) {
|
export default function TourWays({ entries }: { entries: readonly RouteEntry[] }) {
|
||||||
const { tripId } = useAssistantView();
|
const { tripId } = useAssistantView();
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
|
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
|
||||||
import { Route } from 'lucide-react';
|
import {
|
||||||
|
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';
|
||||||
@@ -86,16 +104,45 @@ export const TOUR_LOOK = {
|
|||||||
verbs: {
|
verbs: {
|
||||||
addTour: { Icon: Route, tone: 'success', labelKey: 'assistant.change.verb.addTour' },
|
addTour: { Icon: Route, tone: 'success', labelKey: 'assistant.change.verb.addTour' },
|
||||||
updateTour: { Icon: Route, tone: 'info', labelKey: 'assistant.change.verb.updateTour' },
|
updateTour: { Icon: Route, tone: 'info', labelKey: 'assistant.change.verb.updateTour' },
|
||||||
|
markVisited: { Icon: CircleCheck, tone: 'success', labelKey: 'assistant.change.verb.markVisited' },
|
||||||
|
markSkipped: { Icon: CircleSlash, tone: 'info', labelKey: 'assistant.change.verb.markSkipped' },
|
||||||
|
markPlanned: { Icon: Circle, tone: 'info', labelKey: 'assistant.change.verb.markPlanned' },
|
||||||
|
assignTrack: { Icon: Footprints, tone: 'success', labelKey: 'assistant.change.verb.assignTrack' },
|
||||||
|
unassignTrack: { Icon: Footprints, tone: 'danger', labelKey: 'assistant.change.verb.unassignTrack' },
|
||||||
|
excludeTour: { Icon: ChartNoAxesColumn, tone: 'info', labelKey: 'assistant.change.verb.excludeTour' },
|
||||||
|
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
|
||||||
|
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
|
||||||
|
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' },
|
||||||
|
reverseTour: { Icon: ArrowLeftRight, tone: 'info', labelKey: 'assistant.change.verb.reverseTour' },
|
||||||
|
closeLoop: { Icon: Repeat, tone: 'info', labelKey: 'assistant.change.verb.closeLoop' },
|
||||||
|
copyTour: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.copyTour' },
|
||||||
|
setRouteColor: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setRouteColor' },
|
||||||
|
classifySki: { Icon: Snowflake, tone: 'info', labelKey: 'assistant.change.verb.classifySki' },
|
||||||
|
setPaddlePlan: { Icon: Waves, tone: 'info', labelKey: 'assistant.change.verb.setPaddlePlan' },
|
||||||
|
decideObstacles: { Icon: TriangleAlert, tone: 'info', labelKey: 'assistant.change.verb.decideObstacles' },
|
||||||
|
restoreTrack: { Icon: History, tone: 'info', labelKey: 'assistant.change.verb.restoreTrack' },
|
||||||
|
addTourStop: { Icon: MapPinPlus, tone: 'success', labelKey: 'assistant.change.verb.addTourStop' },
|
||||||
|
removeTourStop: { Icon: MapPinMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeTourStop' },
|
||||||
},
|
},
|
||||||
tools: {},
|
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' },
|
||||||
list_tours: { stepKey: 'assistant.step.readingTours' },
|
list_tours: { stepKey: 'assistant.step.readingTours' },
|
||||||
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_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,6 +29,7 @@ import {
|
|||||||
sacAt,
|
sacAt,
|
||||||
sacPaint,
|
sacPaint,
|
||||||
steepStretches,
|
steepStretches,
|
||||||
|
takeWaitsKey,
|
||||||
wayGroups,
|
wayGroups,
|
||||||
} from './tourRouteModel';
|
} from './tourRouteModel';
|
||||||
import { sketchOf } from './tourRouteSketchModel';
|
import { sketchOf } from './tourRouteSketchModel';
|
||||||
@@ -136,13 +137,33 @@ describe('tourRouteModel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-AI-TOUR-009: "Plan this" takes a planned hike or a city walk, nothing else', () => {
|
it('FE-AI-TOUR-009: "Plan this" takes a planned way of every type, a city walk, a whole paddle or ski plan', () => {
|
||||||
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(false);
|
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(true);
|
||||||
|
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan' })).toBe(true);
|
||||||
|
// A plan with a leg that could not be planned is no tour yet; a hike is never a plan.
|
||||||
|
const failed = [{ kind: 'legs_failed' as const, at: null, count: 1 }];
|
||||||
|
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan', warnings: failed })).toBe(false);
|
||||||
|
expect(canTake({ ...HIKE, source: 'plan' })).toBe(false);
|
||||||
expect(canTake({ ...HIKE, 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,14 +226,31 @@ export function routePoints(card: TourRouteCard): [number, number][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether "Plan this" can take the proposal: a hike whose way was planned,
|
* Whether "Plan this" can take the proposal: a city walk, a hike, a bike
|
||||||
* or a city walk. A paddle or ski plan, a saved tour and a held change cannot.
|
* tour or a run whose way was planned, or a paddle or ski plan whose every
|
||||||
|
* leg was planned. A saved tour, a held change and the other way of a
|
||||||
|
* saved hike's leg (named by its place, changed with update_tour rather
|
||||||
|
* than created anew) cannot.
|
||||||
*/
|
*/
|
||||||
export function canTake(card: TourRouteCard): boolean {
|
export function canTake(card: TourRouteCard): boolean {
|
||||||
if (card.source !== 'proposal') return false;
|
if (card.source === 'plan') {
|
||||||
return card.activity === 'walk' || (card.activity === 'hike' && card.routed);
|
const whole = card.warnings.every((warning) => warning.kind !== 'legs_failed');
|
||||||
|
return (card.activity === 'kayak' || card.activity === 'ski') && card.routed && whole;
|
||||||
|
}
|
||||||
|
if (card.source !== 'proposal' || card.placeId !== null) return false;
|
||||||
|
return card.activity === 'walk' || (LAND_PLANS.has(card.activity) && card.routed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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,10 +92,25 @@ 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 }>;
|
||||||
|
|
||||||
/** The name of a route's time: by how it was reckoned, else a paddle's own, else a walking time. */
|
/** What each bike and run profile is called, as the Tours tab names it. */
|
||||||
|
export const PROFILE_WORDS = {
|
||||||
|
touring: { labelKey: 'tours.bike.profile.touring' },
|
||||||
|
road: { labelKey: 'tours.bike.profile.road' },
|
||||||
|
gravel: { labelKey: 'tours.bike.profile.gravel' },
|
||||||
|
mtb: { labelKey: 'tours.bike.profile.mtb' },
|
||||||
|
running: { labelKey: 'tours.run.profile.running' },
|
||||||
|
trail: { labelKey: 'tours.run.profile.trail' },
|
||||||
|
} as const satisfies Record<NonNullable<TourRouteCard['routingProfile']>, { labelKey: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of a route's time: by how it was reckoned (a bike tour's router
|
||||||
|
* time is its riding time), else a paddle's own, else a walking time.
|
||||||
|
*/
|
||||||
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ describe('takeInThread', () => {
|
|||||||
it('FE-AI-TOUR-048: a take that names its proposal is matched by it, not by its way', () => {
|
it('FE-AI-TOUR-048: a take that names its proposal is matched by it, not by its way', () => {
|
||||||
const other = { ...GENTLE, line: [[47.9, 12.8, 600], ...GENTLE.line.slice(1)] } satisfies TourRouteCard;
|
const other = { ...GENTLE, line: [[47.9, 12.8, 600], ...GENTLE.line.slice(1)] } satisfies TourRouteCard;
|
||||||
expect(takeInThread([proposal, take(other, {}, 'take_proposal', 'call_1')], 'call_1', HIKE)).toBe('waiting');
|
expect(takeInThread([proposal, take(other, {}, 'take_proposal', 'call_1')], 'call_1', HIKE)).toBe('waiting');
|
||||||
|
// The server joins a take to the answer that drew the proposal: found there too.
|
||||||
|
const joined = { ...proposal, parts: [...proposal.parts, ...take(other, {}, 'take_proposal', 'call_1').parts] };
|
||||||
|
expect(takeInThread([joined], 'call_1', HIKE)).toBe('waiting');
|
||||||
// The same way taken from another proposal leaves this one free.
|
// The same way taken from another proposal leaves this one free.
|
||||||
expect(
|
expect(
|
||||||
takeInThread([proposal, take(HIKE, { approved: true }, 'take_proposal', 'call_2')], 'call_1', HIKE)
|
takeInThread([proposal, take(HIKE, { approved: true }, 'take_proposal', 'call_2')], 'call_1', HIKE)
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export function takeOfProposal(
|
|||||||
): { id: string; state: 'waiting' | 'applied'; placeId: number | null } | null {
|
): { id: string; state: 'waiting' | 'applied'; placeId: number | null } | null {
|
||||||
const from = proposalIndex(messages, toolCallId);
|
const from = proposalIndex(messages, toolCallId);
|
||||||
if (from < 0) return null;
|
if (from < 0) return null;
|
||||||
for (const message of messages.slice(from + 1)) {
|
// The server joins a take to the answer that drew its proposal, so that answer is searched as well as the later ones.
|
||||||
|
for (const message of messages.slice(from)) {
|
||||||
const open = new Map<string, 'waiting' | 'applied'>();
|
const open = new Map<string, 'waiting' | 'applied'>();
|
||||||
for (const part of message.parts) {
|
for (const part of message.parts) {
|
||||||
if (part.type !== 'tool-call' || part.toolName !== TAKE_TOOL || part.approval?.resolution) continue;
|
if (part.type !== 'tool-call' || part.toolName !== TAKE_TOOL || part.approval?.resolution) continue;
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ import TourRouteInRow, { TourRouteLead } from './TourRouteInRow';
|
|||||||
* file.
|
* file.
|
||||||
*/
|
*/
|
||||||
export const TOUR_VIEWS = {
|
export const TOUR_VIEWS = {
|
||||||
tourRoute: { Block: TourRouteBlock, Lead: TourRouteLead, Body: TourRouteInRow },
|
tourRoute: { Block: TourRouteBlock, Lead: TourRouteLead, Body: TourRouteInRow, wideBody: true },
|
||||||
tourList: { Block: TourListCard },
|
tourList: { Block: TourListCard },
|
||||||
} satisfies { [K in 'tourRoute' | 'tourList']: CardView<K> };
|
} satisfies { [K in 'tourRoute' | 'tourList']: CardView<K> };
|
||||||
|
|||||||
@@ -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 } from './tourRouteModel';
|
import { canTake, routePoints, takeWaitsKey } from './tourRouteModel';
|
||||||
import { takeInThread, takeOfProposal } from './tourTakeModel';
|
import { takeInThread, takeOfProposal } from './tourTakeModel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,7 +104,8 @@ function stateAfter(outcome: HoldOutcome, error: string | null): TakeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Plan this" on a proposed hike or city walk: one tap adds the tour. The
|
* "Plan this" on a proposed tour (a hike, a bike tour, a run, a city walk)
|
||||||
|
* or a paddle or ski plan: one tap adds the tour. The
|
||||||
* 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
|
||||||
@@ -148,9 +149,10 @@ 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 = null;
|
let reasonKey: string | 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,10 +1,14 @@
|
|||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
import { Palmtree } from 'lucide-react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../../i18n';
|
import { useTranslation } from '../../../../i18n';
|
||||||
import { safeHexColor } from '../../../../utils/safeColor';
|
import { safeHexColor } from '../../../../utils/safeColor';
|
||||||
import type { CardEntry } from '../cardTypes';
|
import type { CardEntry } from '../cardTypes';
|
||||||
import CardFrame from '../parts/CardFrame';
|
import CardFrame from '../parts/CardFrame';
|
||||||
|
import CardHeader from '../parts/CardHeader';
|
||||||
import { CardOpenButton } from '../parts/CardOpen';
|
import { CardOpenButton } from '../parts/CardOpen';
|
||||||
|
import { CardFoot } from '../parts/CardSection';
|
||||||
import { useViewerId } from '../parts/useViewerId';
|
import { useViewerId } from '../parts/useViewerId';
|
||||||
import VacayBalance from './VacayBalance';
|
import VacayBalance from './VacayBalance';
|
||||||
import { balanceLines, mergeVacayCards, type VacayView } from './vacayCardModel';
|
import { balanceLines, mergeVacayCards, type VacayView } from './vacayCardModel';
|
||||||
@@ -26,7 +30,6 @@ const SQUARE = 'size-2 flex-none rounded-sm';
|
|||||||
const COMPANY = 'size-2 flex-none rounded-sm bg-warning-soft ring-1 ring-inset ring-warning';
|
const COMPANY = 'size-2 flex-none rounded-sm bg-warning-soft ring-1 ring-inset ring-warning';
|
||||||
const TRIP = 'size-1.5 flex-none rounded-full bg-info';
|
const TRIP = 'size-1.5 flex-none rounded-full bg-info';
|
||||||
const OTHER = 'mt-1.5 text-caption text-content-muted';
|
const OTHER = 'mt-1.5 text-caption text-content-muted';
|
||||||
const FOOT = 'mt-1 flex justify-end';
|
|
||||||
|
|
||||||
interface LegendItem {
|
interface LegendItem {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -79,13 +82,14 @@ export default function VacayAnswerBlock({ cards }: { cards: readonly CardEntry[
|
|||||||
const first = own[0] ?? null;
|
const first = own[0] ?? null;
|
||||||
return (
|
return (
|
||||||
<CardFrame label={t('assistant.card.vacay.label')}>
|
<CardFrame label={t('assistant.card.vacay.label')}>
|
||||||
|
<CardHeader icon={Palmtree} tone="info" title={t('assistant.card.vacay.label')} />
|
||||||
{view.months.length > 0 && <VacayMonthStrip view={view} placed={[]} labelOf={() => ''} />}
|
{view.months.length > 0 && <VacayMonthStrip view={view} placed={[]} labelOf={() => ''} />}
|
||||||
<Legend view={view} />
|
<Legend view={view} />
|
||||||
{view.otherDays > 0 && <p className={OTHER}>{t('assistant.card.vacay.otherDays', { count: view.otherDays })}</p>}
|
{view.otherDays > 0 && <p className={OTHER}>{t('assistant.card.vacay.otherDays', { count: view.otherDays })}</p>}
|
||||||
<VacayBalance lines={lines} view={view} mode="read" />
|
<VacayBalance lines={lines} view={view} mode="read" />
|
||||||
<div className={FOOT}>
|
<CardFoot>
|
||||||
<CardOpenButton target={{ kind: 'page', path: vacayPathOf(first) }} labelKey="assistant.card.vacay.open" />
|
<CardOpenButton target={{ kind: 'page', path: vacayPathOf(first) }} labelKey="assistant.card.vacay.open" />
|
||||||
</div>
|
</CardFoot>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
|
import { ASSISTANT_TOUR_FILE_EXTENSIONS, 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 (the server checks again), and how big one file may be. */
|
/** What the booking import reads and the route files taken in as tours (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,6 +16,7 @@ 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;
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ function hasFiles(e: DragEvent): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hands a booking file to the chat, from the paperclip, a paste or a drop on
|
* Hands a booking file or a route file (GPX, KML, KMZ) 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.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user