Compare commits

..

1 Commits

Author SHA1 Message Date
Maurice 19ae607f54 feat(assistant): the assistant opens outside a trip, as a home chat about all trips
A third scope, home, next to trip and journey: threads that name neither a
trip nor a journey (the CHECK now only forbids both), reached under
/api/assistant by their owner only. A home turn reads the user's trips
through list_trips into a short card (under way, next, later, behind,
undated), offers every read (a trip's on the id the model names, checked by
the tool with the user's rights) and only the writes that take no trip or
journey (atlas, vacation planner, collections, tour library, settings), all
held as change cards. open_trip_chat answers with a card that goes on in
that trip's own chat with the question waiting in its composer.

On the desktop the window opens on the home chat outside a trip; its head
lists the trips to switch into and a switched trip chat has the way back.
On the phone the home chat opens full screen from the dashboard head and the
dock's More list, and the card opens the trip with its chat. Both run on
useAssistantHome.
2026-10-23 21:19:25 +02:00
1852 changed files with 15145 additions and 114272 deletions
-51
View File
@@ -53,54 +53,3 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
`server/assets/airports.json` is built from **OurAirports** `server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain. (https://ourairports.com/data/), released into the public domain.
## Typefaces: Studio books and the app
Every typeface a book can be set in, and the app's own (Poppins, MuseoModerno,
Geist), is self-hosted from the **@fontsource** npm packages, which redistribute
the families published on Google Fonts split into subsets by script. The catalogue of book faces is
`shared/src/book/book-fonts.data.ts`. Each package carries its licence and the
copyright notice of the family's authors in its `LICENSE` file.
Both licences allow a font to be bundled with software and embedded in a
document made with it, so a printed or exported book may carry the faces it is
set in. No family is renamed, redrawn or sold on its own. SIL OFL 1.1: https://openfontlicense.org. Apache 2.0:
https://www.apache.org/licenses/LICENSE-2.0
| Family | Package | Licence |
|---|---|---|
| Abril Fatface | `@fontsource/abril-fatface` | SIL Open Font License 1.1 |
| Alfa Slab One | `@fontsource/alfa-slab-one` | SIL Open Font License 1.1 |
| Allura | `@fontsource/allura` | SIL Open Font License 1.1 |
| Anton | `@fontsource/anton` | SIL Open Font License 1.1 |
| Archivo Black | `@fontsource/archivo-black` | SIL Open Font License 1.1 |
| Bebas Neue | `@fontsource/bebas-neue` | SIL Open Font License 1.1 |
| Big Shoulders Display | `@fontsource/big-shoulders-display` | SIL Open Font License 1.1 |
| Bodoni Moda | `@fontsource/bodoni-moda` | SIL Open Font License 1.1 |
| Bricolage Grotesque | `@fontsource/bricolage-grotesque` | SIL Open Font License 1.1 |
| Caveat | `@fontsource/caveat` | SIL Open Font License 1.1 |
| Cormorant Garamond | `@fontsource/cormorant-garamond` | SIL Open Font License 1.1 |
| Dancing Script | `@fontsource/dancing-script` | SIL Open Font License 1.1 |
| DM Mono | `@fontsource/dm-mono` | SIL Open Font License 1.1 |
| DM Serif Display | `@fontsource/dm-serif-display` | SIL Open Font License 1.1 |
| EB Garamond | `@fontsource/eb-garamond` | SIL Open Font License 1.1 |
| Fraunces | `@fontsource/fraunces` | SIL Open Font License 1.1 |
| Great Vibes | `@fontsource/great-vibes` | SIL Open Font License 1.1 |
| Homemade Apple | `@fontsource/homemade-apple` | Apache License 2.0 |
| Inter | `@fontsource/inter` | SIL Open Font License 1.1 |
| Kaushan Script | `@fontsource/kaushan-script` | SIL Open Font License 1.1 |
| League Gothic | `@fontsource/league-gothic` | SIL Open Font License 1.1 |
| Lora | `@fontsource/lora` | SIL Open Font License 1.1 |
| Montserrat | `@fontsource/montserrat` | SIL Open Font License 1.1 |
| MuseoModerno | `@fontsource/museomoderno` | SIL Open Font License 1.1 |
| Oswald | `@fontsource/oswald` | SIL Open Font License 1.1 |
| Pacifico | `@fontsource/pacifico` | SIL Open Font License 1.1 |
| Parisienne | `@fontsource/parisienne` | SIL Open Font License 1.1 |
| Permanent Marker | `@fontsource/permanent-marker` | Apache License 2.0 |
| Playfair Display | `@fontsource/playfair-display` | SIL Open Font License 1.1 |
| Poppins | `@fontsource/poppins` | SIL Open Font License 1.1 |
| Sacramento | `@fontsource/sacramento` | SIL Open Font License 1.1 |
| Satisfy | `@fontsource/satisfy` | Apache License 2.0 |
| Shrikhand | `@fontsource/shrikhand` | SIL Open Font License 1.1 |
| Yellowtail | `@fontsource/yellowtail` | Apache License 2.0 |
| Geist Sans | `@fontsource/geist-sans` | SIL Open Font License 1.1 |
-3
View File
@@ -112,9 +112,6 @@ data:
{{- if .Values.env.TREK_PLACES_URL }} {{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }} TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }} {{- end }}
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
{{- end }}
{{- if .Values.env.AMAP_API_BASE }} {{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }} AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }} {{- end }}
-1
View File
@@ -108,7 +108,6 @@ env:
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided # nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on. # here. Defaults to on.
# TREK_PLACES_URL: "" # TREK_PLACES_URL: ""
# GOOGLE_LIST_DATA_BASE: ""
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK. # configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com. # Defaults to https://places.liketrek.com.
-67
View File
@@ -1,67 +0,0 @@
import { BOOK_FONT_FACES, BOOK_FONTS_PRECACHED } from '@trek/shared';
/**
* Which font files the service worker installs with the app.
*
* The precache takes every woff and woff2 the build emits, and with the book's
* catalogue at thirty four families that is every weight, both styles and
* every script subset of each: megabytes fetched at install by every user,
* most of it for faces no book of theirs is set in. So the book's faces are
* held to this:
*
* - The faces the app itself draws (Poppins and MuseoModerno for the chrome,
* imported in src/main.tsx) stay exactly as they were.
* - The faces the book shipped with before the catalogue grew
* (`BOOK_FONTS_PRECACHED`) keep their latin woff2, so a book set in them
* still opens offline before Studio was ever opened online.
* - Every other file of a book family, woff, other subsets, new families,
* real italics, is left to the `book-fonts` runtime cache in vite.config.js,
* which the Studio fills with the faces of each book it opens
* (src/components/Studio/bookFontWarm.ts).
*
* Shared by vite.config.js and its test, which reads the filter against file
* names as the build writes them.
*/
/** The faces src/main.tsx imports for the app chrome, which the book shares; bookFontPrecache.test.ts checks this copy. */
export const APP_FACES = {
poppins: [300, 400, 500, 600, 700],
museomoderno: [400, 700, 800],
};
/** The book packages, longest name first, so `dm-serif-display` is never read as `dm-…`. */
const BOOK_PACKAGES = [...new Set(Object.values(BOOK_FONT_FACES).map((f) => f.pkg))].sort(
(a, b) => b.length - a.length
);
const PRECACHED = new Map(
Object.entries(BOOK_FONTS_PRECACHED).map(([id, weights]) => [BOOK_FONT_FACES[id].pkg, new Set(weights)])
);
/**
* A font file as the build names it, `<package>-<subset>-<weight>-<style>-<hash>.<ext>`,
* read against the book's packages; null for any other file.
*/
export function bookFontFile(url) {
const name = url.split('/').pop() ?? '';
const match = /^(.+)-(\d{3})-(normal|italic)-[\w-]+\.(woff2?)$/.exec(name);
if (!match) return null;
const [, head, weight, style, ext] = match;
const pkg = BOOK_PACKAGES.find((p) => head.startsWith(`${p}-`));
if (!pkg) return null;
return { pkg, subset: head.slice(pkg.length + 1), weight: Number(weight), italic: style === 'italic', ext };
}
/** Whether a file of the build goes into the install-time precache. */
export function keepInPrecache(url) {
const file = bookFontFile(url);
if (!file) return true;
if (!file.italic && APP_FACES[file.pkg]?.includes(file.weight)) return true;
const kept = PRECACHED.get(file.pkg);
return !file.italic && file.subset === 'latin' && file.ext === 'woff2' && !!kept?.has(file.weight);
}
/** The precache manifest without the book faces left to the runtime cache: a Workbox `manifestTransforms` entry. */
export function withoutRuntimeFonts(entries) {
return { manifest: entries.filter((entry) => keepInPrecache(entry.url)), warnings: [] };
}
+5 -165
View File
@@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request,
// ── Studio ──────────────────────────────────────────────────────────────────── // ── Studio ────────────────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.st-rail') const rail = (page: Page) => page.locator('.st-rail')
const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true }) const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true })
const sidePanel = (page: Page) => page.locator('.st-panel.st-side') const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
const inspector = (page: Page) => page.locator('.st-inspector') const inspector = (page: Page) => page.locator('.st-inspector')
const studioMenu = (page: Page) => page.locator('.st-menu').last() const studioMenu = (page: Page) => page.locator('.st-menu').last()
@@ -460,20 +460,6 @@ const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' }
const thumbs = (page: Page) => page.locator('.st-thumb-row') const thumbs = (page: Page) => page.locator('.st-thumb-row')
/** The hit targets over the spread, one per element. */ /** The hit targets over the spread, one per element. */
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]') const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
/**
* The hit target of the first text on the spread on screen. The targets above the page are empty
* boxes, so the one over the drawn words is found by where the words are and marked for the locator.
*/
async function markTextTarget(page: Page): Promise<void> {
const words = page.locator('.st-sheet').getByText(/\p{L}{3,}/u).first()
await expect(words).toBeVisible()
await words.evaluate(el => {
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
hit?.setAttribute('data-help-text', '')
})
}
const textTarget = (page: Page) => page.locator('.st-sheet [data-help-text]')
async function studioSection(page: Page, name: string): Promise<void> { async function studioSection(page: Page, name: string): Promise<void> {
await railButton(page, name).click() await railButton(page, name).click()
@@ -1314,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
}, },
}, },
{ {
target: p => p.locator('.st-head-close'), target: p => p.locator('.st-back'),
}, },
], ],
}, },
@@ -1445,7 +1431,7 @@ const SCRIPTS: Record<string, GuideScript> = {
}, },
{ {
prepare: async p => { prepare: async p => {
await sidePanel(p).getByRole('tab', { name: /Entries/ }).click() await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click()
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible() await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
await settle(p) await settle(p)
}, },
@@ -1472,162 +1458,16 @@ const SCRIPTS: Record<string, GuideScript> = {
], ],
}, },
'studio-my-layouts': {
guide: guide('studio-my-layouts'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => railButton(p, 'Layouts'),
act: async p => { await studioSection(p, 'Layouts') },
},
{
// Pointed at rather than pressed, so the seed book keeps no layout of its own for the guides after this one.
prepare: async p => {
await sidePanel(p).getByRole('button', { name: 'Save this page as a layout' }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => sidePanel(p).locator('.st-saved-layouts'),
},
],
},
'studio-theme': {
guide: guide('studio-theme'),
start: openStudio,
steps: [
{
target: p => railButton(p, 'Theme'),
act: async p => { await studioSection(p, 'Theme') },
},
{
target: p => sidePanel(p).locator('.st-theme-grid'),
act: async p => {
await sidePanel(p).locator('.st-theme-card').nth(1).click()
await settle(p)
},
},
{
prepare: async p => {
await sidePanel(p).getByRole('button', { name: 'Apply to the book' }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => sidePanel(p).locator('.st-theme-apply'),
},
],
},
'studio-stickers': {
guide: guide('studio-stickers'),
start: openStudio,
steps: [
{
prepare: async p => { await studioSection(p, 'Elements') },
target: p => sidePanel(p).getByRole('tab', { name: 'Stickers' }),
act: async p => {
await sidePanel(p).getByRole('tab', { name: 'Stickers' }).click()
await expect(sidePanel(p).locator('.st-shape-grid').first()).toBeVisible()
await settle(p)
},
},
{
target: p => sidePanel(p).locator('.st-shape-grid').first(),
},
],
},
'studio-travel': { 'studio-travel': {
guide: guide('studio-travel'), guide: guide('studio-travel'),
start: openStudio, start: openStudio,
steps: [ steps: [
{ {
target: p => railButton(p, 'Travel'), target: p => railButton(p, 'Travel'),
act: async p => { act: async p => { await studioSection(p, 'Travel') },
await studioSection(p, 'Travel')
await expect(sidePanel(p).locator('.st-travel-card').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
}, },
{ {
target: p => sidePanel(p).locator('.st-travel-grid').first(), target: sidePanel,
},
{
target: p => sidePanel(p).locator('.st-travel-card').first(),
},
],
},
'studio-text-effects': {
guide: guide('studio-text-effects'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
await markTextTarget(p)
},
target: p => textTarget(p),
act: async p => {
await textTarget(p).click()
await expect(inspector(p).getByRole('button', { name: 'Effects' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
const head = inspector(p).getByRole('button', { name: 'Effects' })
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click()
await head.scrollIntoViewIfNeeded()
await settle(p)
},
target: p => inspector(p).locator('.st-section').filter({ has: p.getByRole('button', { name: 'Effects' }) }),
},
],
},
'studio-background': {
guide: guide('studio-background'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => inspector(p).locator('.st-section').filter({ hasText: 'Page background' }).first(),
},
{
target: p => inspector(p).locator('.st-paint-tabs').first(),
},
],
},
'studio-layers': {
guide: guide('studio-layers'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => railButton(p, 'Layers'),
act: async p => {
await studioSection(p, 'Layers')
await expect(sidePanel(p).locator('.st-layer').first()).toBeVisible()
await settle(p)
},
},
{
target: p => sidePanel(p).locator('.st-layer').first(),
}, },
], ],
}, },
+1 -28
View File
@@ -47,41 +47,14 @@
}, },
"dependencies": { "dependencies": {
"@assistant-ui/react": "0.15.25", "@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
"@fontsource/alfa-slab-one": "^5.3.0",
"@fontsource/allura": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@fontsource/bebas-neue": "^5.3.0", "@fontsource/bebas-neue": "^5.3.0",
"@fontsource/big-shoulders-display": "^5.3.0",
"@fontsource/bodoni-moda": "^5.3.0",
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/caveat": "^5.3.0",
"@fontsource/cormorant-garamond": "^5.3.0",
"@fontsource/dancing-script": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0",
"@fontsource/dm-serif-display": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0", "@fontsource/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0", "@fontsource/geist-sans": "^5.3.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/homemade-apple": "^5.3.0",
"@fontsource/inter": "^5.3.0", "@fontsource/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0", "@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0", "@fontsource/museomoderno": "^5.3.0",
"@fontsource/oswald": "^5.3.0",
"@fontsource/pacifico": "^5.3.0",
"@fontsource/parisienne": "^5.3.0",
"@fontsource/permanent-marker": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0", "@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0", "@fontsource/poppins": "^5.3.0",
"@fontsource/sacramento": "^5.3.0",
"@fontsource/satisfy": "^5.3.0",
"@fontsource/shrikhand": "^5.3.0",
"@fontsource/yellowtail": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0", "@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4", "@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
@@ -108,7 +81,7 @@
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tz-lookup": "^6.1.25", "tz-lookup": "^6.1.25",
"zod": "^4.6.7", "zod": "^4.6.5",
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+12 -12
View File
@@ -1,26 +1,26 @@
{ {
"src": { "src": {
"@typescript-eslint/no-non-null-assertion": 384, "@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 849, "@typescript-eslint/no-misused-promises": 854,
"no-console": 88, "no-console": 88,
"complexity": 222, "complexity": 226,
"@typescript-eslint/no-explicit-any": 146, "@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130, "trek/no-swallowed-catch": 131,
"max-params": 32, "max-params": 33,
"trek/store-without-selector": 67, "trek/store-without-selector": 67,
"react-hooks/refs": 556, "react-hooks/refs": 557,
"react-hooks/set-state-in-effect": 194, "react-hooks/set-state-in-effect": 195,
"@typescript-eslint/no-floating-promises": 165, "@typescript-eslint/no-floating-promises": 165,
"react-hooks/exhaustive-deps": 116, "react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124, "@typescript-eslint/no-unused-vars": 126,
"react-hooks/immutability": 28, "react-hooks/immutability": 28,
"max-lines-per-function": 130, "max-lines-per-function": 132,
"react-hooks/static-components": 78, "react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 12, "react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7, "no-useless-assignment": 7,
"no-empty": 19, "no-empty": 19,
"trek/no-window-globals": 41, "trek/no-window-globals": 41,
"react-refresh/only-export-components": 45, "react-refresh/only-export-components": 46,
"unused-disable-directive": 6, "unused-disable-directive": 6,
"max-depth": 21, "max-depth": 21,
"react-hooks/void-use-memo": 1, "react-hooks/void-use-memo": 1,
@@ -34,8 +34,8 @@
"react-hooks/exhaustive-deps": 46 "react-hooks/exhaustive-deps": 46
}, },
"tests": { "tests": {
"@typescript-eslint/no-explicit-any": 490, "@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1929, "@typescript-eslint/no-non-null-assertion": 1932,
"@typescript-eslint/no-misused-promises": 22, "@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51, "@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33, "@typescript-eslint/no-floating-promises": 33,
+1
View File
@@ -601,6 +601,7 @@
"hooks": ["src/components/Planner/usePlaceActions.ts"], "hooks": ["src/components/Planner/usePlaceActions.ts"],
"desktop": [ "desktop": [
"src/components/Planner/PlaceInspector.tsx", "src/components/Planner/PlaceInspector.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/pages/tripPlanner/useTripPlannerPage.ts" "src/pages/tripPlanner/useTripPlannerPage.ts"
], ],
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"] "mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
+2
View File
@@ -1123,6 +1123,8 @@
"src/components/shared/PasswordChecklist.tsx", "src/components/shared/PasswordChecklist.tsx",
"src/components/shared/PlaceAvatar.test.tsx", "src/components/shared/PlaceAvatar.test.tsx",
"src/components/shared/PlaceAvatar.tsx", "src/components/shared/PlaceAvatar.tsx",
"src/components/shared/PlaceAvatarUpload.test.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/components/shared/PluginIcon.test.tsx", "src/components/shared/PluginIcon.test.tsx",
"src/components/shared/PluginIcon.tsx", "src/components/shared/PluginIcon.tsx",
"src/components/shared/PublicLanguagePicker.test.tsx", "src/components/shared/PublicLanguagePicker.test.tsx",
+5 -14
View File
@@ -117,6 +117,11 @@ export const DYNAMIC_ALLOWED = [
template: 'settings.${mode}', template: 'settings.${mode}',
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist", because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
}, },
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
file,
template: 'journey.studio.${key}',
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
})),
{ {
file: 'mobile/screens/trip/sheets/MBookingFields.tsx', file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
template: 'reservations.${s}', template: 'reservations.${s}',
@@ -139,20 +144,6 @@ export const DYNAMIC_ALLOWED = [
'memories.* key in en', 'memories.* key in en',
})) }))
), ),
{
file: 'components/Studio/stickerLibrary.ts',
template: 'journey.studio.sticker.${id}',
because:
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
'sticker the library holds against en',
},
{
file: 'components/Studio/shapes.ts',
template: 'journey.studio.shapeName.${shape}',
because:
'shapeName takes a BookShapeId; shared/src/book/book-shape-catalogue.spec.ts holds an en name for every ' +
'shape of BOOK_SHAPE_GROUPS',
},
]; ];
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/; const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
+1 -1
View File
@@ -26,7 +26,7 @@
"packing.ts": 6, "packing.ts": 6,
"pdf.ts": 1, "pdf.ts": 1,
"perm.ts": 4, "perm.ts": 4,
"places.ts": 9, "places.ts": 10,
"planner.ts": 53, "planner.ts": 53,
"reservations.ts": 21, "reservations.ts": 21,
"roadtrip.ts": 36, "roadtrip.ts": 36,
+2 -2
View File
@@ -38,8 +38,8 @@
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1, "src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1, "src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
"src/components/Studio/StudioShell.tsx": 1, "src/components/Studio/StudioShell.tsx": 1,
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1, "src/components/Studio/StudioSidebar.tsx": 1,
"src/components/Studio/travel/BadgeIcon.tsx": 1, "src/components/Studio/TravelElements.tsx": 1,
"src/components/Todo/useTodoList.ts": 1, "src/components/Todo/useTodoList.ts": 1,
"src/components/shared/ConfirmDialog.tsx": 1, "src/components/shared/ConfirmDialog.tsx": 1,
"src/components/shared/CountryFlag.tsx": 1, "src/components/shared/CountryFlag.tsx": 1,
+1
View File
@@ -116,6 +116,7 @@
"src/components/Vacay/useVacayCalendarLogic.ts", "src/components/Vacay/useVacayCalendarLogic.ts",
"src/components/Vacay/useVacaySettings.ts", "src/components/Vacay/useVacaySettings.ts",
"src/components/Vacay/useVacayUserPicker.ts", "src/components/Vacay/useVacayUserPicker.ts",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/hooks/useAirtrailConnection.ts", "src/hooks/useAirtrailConnection.ts",
"src/hooks/useDawarichConnection.ts", "src/hooks/useDawarichConnection.ts",
"src/hooks/useDawarichSuggestions.ts", "src/hooks/useDawarichSuggestions.ts",
+6 -3
View File
@@ -18,6 +18,7 @@
"components/Journey/JourneyMap.tsx": 1, "components/Journey/JourneyMap.tsx": 1,
"components/Journey/JourneyMapGL.tsx": 2, "components/Journey/JourneyMapGL.tsx": 2,
"components/Journey/MobileEntryView.tsx": 1, "components/Journey/MobileEntryView.tsx": 1,
"components/Journey/PhotoLightbox.tsx": 3,
"components/Journey/journeyPhotoLayer.ts": 1, "components/Journey/journeyPhotoLayer.ts": 1,
"components/Layout/Navbar.tsx": 1, "components/Layout/Navbar.tsx": 1,
"components/Layout/OfflineBanner.tsx": 1, "components/Layout/OfflineBanner.tsx": 1,
@@ -51,12 +52,13 @@
"components/Studio/BookSheetsView.tsx": 3, "components/Studio/BookSheetsView.tsx": 3,
"components/Studio/PageNumbers.tsx": 1, "components/Studio/PageNumbers.tsx": 1,
"components/Studio/PeerCursors.tsx": 1, "components/Studio/PeerCursors.tsx": 1,
"components/Studio/PhotoView.tsx": 4, "components/Studio/SpreadView.tsx": 8,
"components/Studio/SpreadView.tsx": 4, "components/Studio/StudioCanvas.tsx": 22,
"components/Studio/StudioCanvas.tsx": 12,
"components/Studio/StudioElementsPanel.tsx": 1, "components/Studio/StudioElementsPanel.tsx": 1,
"components/Studio/StudioExport.tsx": 1, "components/Studio/StudioExport.tsx": 1,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioSavedLayouts.tsx": 1, "components/Studio/StudioSavedLayouts.tsx": 1,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 4, "components/Studio/TravelElements.tsx": 4,
"components/Studio/TravelPreview.tsx": 1, "components/Studio/TravelPreview.tsx": 1,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3, "components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
@@ -71,6 +73,7 @@
"components/shared/DetailShell.tsx": 1, "components/shared/DetailShell.tsx": 1,
"components/shared/ElevationProfile.tsx": 2, "components/shared/ElevationProfile.tsx": 2,
"components/shared/NavigationMenu.tsx": 2, "components/shared/NavigationMenu.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 1, "components/shared/PublicLanguagePicker.tsx": 1,
"components/shared/SlidingTabs.tsx": 3, "components/shared/SlidingTabs.tsx": 3,
"components/shared/StarRating.tsx": 3, "components/shared/StarRating.tsx": 3,
+2 -5
View File
@@ -1,7 +1,4 @@
{ {
"components/Studio/LineHandles.tsx": 1, "pages/tripPlanner/PlanMapStage.tsx": 4,
"components/Studio/StudioSelectionChrome.tsx": 2, "mobile/components/trek3d/MascotClip.tsx": 1
"components/Studio/inspector/FocalPad.tsx": 1,
"mobile/components/trek3d/MascotClip.tsx": 1,
"pages/tripPlanner/PlanMapStage.tsx": 4
} }
+7 -7
View File
@@ -8,23 +8,23 @@
"src/components/Map/MapView.tsx": 1399, "src/components/Map/MapView.tsx": 1399,
"src/components/Map/MapViewGL.test.tsx": 2920, "src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2290, "src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1076, "src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082, "src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333, "src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349, "src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2936, "src/components/Planner/DayPlanSidebar.tsx": 2953,
"src/components/Planner/PlaceFormModal.test.tsx": 2836, "src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1157, "src/components/Planner/PlaceFormModal.tsx": 1193,
"src/components/Planner/PlaceInspector.tsx": 1290, "src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1559, "src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/countryShapes.ts": 2595, "src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636, "src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507, "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030, "src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348, "src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1128, "src/pages/TripPlannerPage.tsx": 1131,
"src/pages/atlas/useAtlas.ts": 1211, "src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270, "src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 1918 "src/styles/studio.css": 2087
} }
+4 -1
View File
@@ -51,7 +51,7 @@
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1, "src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
"src/components/Planner/PlaceFormModal.test.tsx": 3, "src/components/Planner/PlaceFormModal.test.tsx": 3,
"src/components/Planner/PlaceFormModal.tsx": 1, "src/components/Planner/PlaceFormModal.tsx": 1,
"src/components/Planner/PlaceInspector.tsx": 16, "src/components/Planner/PlaceInspector.tsx": 17,
"src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1, "src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1,
"src/components/Planner/PlacesSidebar.tsx": 1, "src/components/Planner/PlacesSidebar.tsx": 1,
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8, "src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
@@ -82,6 +82,8 @@
"src/components/Settings/NotificationsTab.test.tsx": 1, "src/components/Settings/NotificationsTab.test.tsx": 1,
"src/components/Settings/PasskeysSection.tsx": 1, "src/components/Settings/PasskeysSection.tsx": 1,
"src/components/Settings/PhotoProvidersSection.test.tsx": 2, "src/components/Settings/PhotoProvidersSection.test.tsx": 2,
"src/components/Studio/StudioCanvas.tsx": 1,
"src/components/Studio/StudioInspector.tsx": 1,
"src/components/Todo/todoListModel.ts": 1, "src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2, "src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2, "src/components/Tours/planner/useTourPlanner.ts": 2,
@@ -96,6 +98,7 @@
"src/components/shared/CustomSelect.test.tsx": 1, "src/components/shared/CustomSelect.test.tsx": 1,
"src/components/shared/PlaceAvatar.test.tsx": 2, "src/components/shared/PlaceAvatar.test.tsx": 2,
"src/components/shared/PlaceAvatar.tsx": 1, "src/components/shared/PlaceAvatar.tsx": 1,
"src/components/shared/PlaceAvatarUpload.test.tsx": 1,
"src/hooks/useDawarichSuggestions.test.ts": 1, "src/hooks/useDawarichSuggestions.test.ts": 1,
"src/mobile/screens/admin/MAdminAddonManager.tsx": 2, "src/mobile/screens/admin/MAdminAddonManager.tsx": 2,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6, "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6,
+8 -7
View File
@@ -52,7 +52,7 @@
"components/Journey/moodConfig.ts": 5, "components/Journey/moodConfig.ts": 5,
"components/Layout/BottomNav.tsx": 3, "components/Layout/BottomNav.tsx": 3,
"components/Layout/DemoBanner.tsx": 25, "components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 3, "components/Layout/InAppNotificationBell.tsx": 8,
"components/Layout/MobileTopBar.tsx": 51, "components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21, "components/Layout/Navbar.tsx": 21,
"components/Layout/NewVersionNotice.tsx": 3, "components/Layout/NewVersionNotice.tsx": 3,
@@ -72,7 +72,7 @@
"components/Map/poiCategories.ts": 7, "components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2, "components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1, "components/Map/reservationsMapbox.ts": 1,
"components/Notifications/InAppNotificationItem.tsx": 1, "components/Notifications/InAppNotificationItem.tsx": 8,
"components/PDF/TripPDF.tsx": 53, "components/PDF/TripPDF.tsx": 53,
"components/Packing/PackingListPanelBagModal.tsx": 2, "components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5, "components/Packing/PackingListPanelCategoryGroup.tsx": 5,
@@ -100,13 +100,13 @@
"components/Roadtrip/serviceMarker.ts": 1, "components/Roadtrip/serviceMarker.ts": 1,
"components/Settings/useAppearanceEditor.ts": 5, "components/Settings/useAppearanceEditor.ts": 5,
"components/Studio/BookSheetsView.tsx": 2, "components/Studio/BookSheetsView.tsx": 2,
"components/Studio/PhotoView.tsx": 2, "components/Studio/SpreadView.tsx": 3,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 8, "components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 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,6 +132,7 @@
"components/shared/Modal.tsx": 3, "components/shared/Modal.tsx": 3,
"components/shared/NavigationMenu.tsx": 1, "components/shared/NavigationMenu.tsx": 1,
"components/shared/NoteColorPicker.tsx": 2, "components/shared/NoteColorPicker.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 3, "components/shared/PublicLanguagePicker.tsx": 3,
"components/shared/Skeleton.tsx": 2, "components/shared/Skeleton.tsx": 2,
"components/shared/Spinner.tsx": 2, "components/shared/Spinner.tsx": 2,
@@ -251,7 +252,7 @@
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3, "mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1, "mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2, "mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2,
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 23, "mobile/screens/trip/sheets/MPlaceSheet.tsx": 27,
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2, "mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9, "mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12, "mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
@@ -295,7 +296,7 @@
"pages/FilesPage.tsx": 11, "pages/FilesPage.tsx": 11,
"pages/ForgotPasswordPage.tsx": 18, "pages/ForgotPasswordPage.tsx": 18,
"pages/HelpPage.tsx": 21, "pages/HelpPage.tsx": 21,
"pages/InAppNotificationsPage.tsx": 2, "pages/InAppNotificationsPage.tsx": 9,
"pages/JoinTripPage.tsx": 4, "pages/JoinTripPage.tsx": 4,
"pages/JourneyDetailPage.tsx": 53, "pages/JourneyDetailPage.tsx": 53,
"pages/JourneyPage.tsx": 64, "pages/JourneyPage.tsx": 64,
+2 -4
View File
@@ -40,14 +40,12 @@
"components/Settings/AppearanceSettingsTab.tsx": 1, "components/Settings/AppearanceSettingsTab.tsx": 1,
"components/Settings/PasskeysSection.tsx": 1, "components/Settings/PasskeysSection.tsx": 1,
"components/Studio/BookSheetsView.tsx": 1, "components/Studio/BookSheetsView.tsx": 1,
"components/Studio/PhotoView.tsx": 1, "components/Studio/SpreadView.tsx": 2,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 2, "components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1, "components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 3, "components/Studio/TravelElements.tsx": 6,
"components/Studio/peerColour.ts": 1, "components/Studio/peerColour.ts": 1,
"components/Studio/printSheets.ts": 4, "components/Studio/printSheets.ts": 4,
"components/Studio/travel/badgeLook.ts": 2,
"components/Trips/TripMemberAvatar.tsx": 2, "components/Trips/TripMemberAvatar.tsx": 2,
"components/Vacay/VacayPersons.tsx": 1, "components/Vacay/VacayPersons.tsx": 1,
"components/shared/DialogShell.tsx": 2, "components/shared/DialogShell.tsx": 2,
-8
View File
@@ -18,7 +18,6 @@ import {
type AssistantChatRequest, type AssistantChatRequest,
type AssistantDecisionsRequest, type AssistantDecisionsRequest,
type AssistantErrorCode, type AssistantErrorCode,
type AssistantInsertFoundPlaceRequest,
type AssistantRenderUploadResponse, type AssistantRenderUploadResponse,
type AssistantScope, type AssistantScope,
type AssistantShareRequest, type AssistantShareRequest,
@@ -146,13 +145,6 @@ export const assistantApi = {
body: AssistantAddFoundPlaceRequest body: AssistantAddFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> => ): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data), apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
// "+ Tour": a found place taken into the saved tour open in the planner, held as a change like "+ Day 3".
insertFoundPlace: (
tripId: number,
threadId: number,
body: AssistantInsertFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places/tour`, body).then((r) => r.data),
// A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it. // A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
takeProposal: ( takeProposal: (
tripId: number, tripId: number,
@@ -712,33 +712,4 @@ describe('AddonManager', () => {
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument(); expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument(); expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
}); });
it('FE-ADMIN-ADDON-045: finds an addon by name or description and shows the ones on or off', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([
buildAddon({ id: 'x-lists', name: 'Todo List', description: 'Track tasks', type: 'trip', enabled: true }),
buildAddon({ id: 'x-costs', name: 'Budget', description: 'Split costs', type: 'trip', enabled: false }),
buildAddon({ id: 'x-holiday', name: 'Vacay', description: 'Holiday planner', type: 'global', enabled: true }),
])
);
render(<AddonManager />);
await screen.findByText('Todo List');
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'costs');
expect(screen.getByText('Budget')).toBeInTheDocument();
expect(screen.queryByText('Todo List')).toBeNull();
expect(screen.queryByText('Vacay')).toBeNull();
await user.clear(screen.getByRole('searchbox', { name: 'Search addons' }));
await user.click(screen.getByRole('button', { name: 'On' }));
expect(screen.getByText('Todo List')).toBeInTheDocument();
expect(screen.getByText('Vacay')).toBeInTheDocument();
expect(screen.queryByText('Budget')).toBeNull();
await user.click(screen.getByRole('button', { name: 'Off' }));
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'nothing like this');
expect(screen.getByText('No addon matches the search or the filter.')).toBeInTheDocument();
});
}); });
+59 -138
View File
@@ -1,7 +1,7 @@
import { useState, type ComponentType } from 'react' import type { ComponentType } from 'react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark' import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Search } from 'lucide-react' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon' import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon' import AirTrailIcon from '../shared/AirTrailIcon'
@@ -76,8 +76,6 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
toggleToursWanderer, toggleToursWanderer,
mapSwitches, mapSwitches,
} = useAddonManager() } = useAddonManager()
const [query, setQuery] = useState('')
const [view, setView] = useState<AddonView>('all')
if (loading) { if (loading) {
return ( return (
@@ -196,10 +194,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
) )
} }
/* A card per type, the way every other admin tab sets its cards: Trip in the /* One column per type, side by side, instead of three stacked sections. Stacked, each
left column, Global and Integration in the right one, each card with the section opened its own set of columns and the tall tiles (Collab, Journey) set a
dialogs' head band carrying the type, its count and what it means. Above section's height while its neighbours ran out early. Side by side the column's
them one card says how many are on and finds an addon by name. */ head carries the type, so the tiles need no badge of their own. */
const groups = [ const groups = [
{ key: 'trip', addons: tripAddons }, { key: 'trip', addons: tripAddons },
{ key: 'global', addons: globalAddons }, { key: 'global', addons: globalAddons },
@@ -216,145 +214,68 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span> </span>
) )
/** Whether an addon is shown under the search and the filter. */
const shown = (addon: Addon) => {
if (view === 'on' && !addon.enabled) return false
if (view === 'off' && addon.enabled) return false
const q = query.trim().toLocaleLowerCase()
if (!q) return true
const label = getAddonLabel(t, addon)
return `${label.name} ${label.description ?? ''}`.toLocaleLowerCase().includes(q)
}
const card = (g: (typeof groups)[number]) => {
const meta = TYPE_META[g.key]
const all = g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons
const visible = g.addons.filter(shown)
const ai = g.key === 'integration' && aiAddons.some(shown)
if (!visible.length && !ai) return null
const on = all.filter(a => a.enabled).length
return (
<SettingsCard
key={g.key}
icon={meta.icon}
title={t(meta.labelKey)}
hint={t(meta.hintKey)}
badge={(
<Tooltip label={t('admin.addons.group.count', { enabled: on, total: all.length })}>
<span className="inline-flex"><StatusPill tone={on > 0 ? 'success' : 'neutral'}>{on}/{all.length}</StatusPill></span>
</Tooltip>
)}
bodyClassName="p-3"
>
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{/* The AI addons share a connection and a settings dialog, so they head the card as one tile. */}
{ai && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{visible.map(tile)}
</div>
</SettingsCard>
)
}
const [first, ...rest] = groups
const cards = { start: first ? card(first) : null, end: rest.map(card).filter(Boolean) }
return ( return (
<> <SettingsCard
<SettingsCard icon={Puzzle}
icon={Puzzle} title={t('admin.addons.title')}
title={t('admin.addons.title')} hint={subtitle}
hint={subtitle} badge={totalCount > 0 ? (
badge={totalCount > 0 ? ( <Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}> <span className="inline-flex">
<span className="inline-flex"> <StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill> </span>
</span> </Tooltip>
</Tooltip> ) : undefined}
) : undefined} >
> {addons.length === 0 ? (
{addons.length === 0 <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
? <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
: <AddonToolbar groups={groups.map(g => ({ key: g.key, addons: g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons }))} query={query} onQuery={setQuery} view={view} onView={setView} t={t} />}
</SettingsCard>
{addons.length > 0 && (cards.start || cards.end.length > 0 ? (
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
<div className="min-w-0">{cards.start}</div>
<div className="min-w-0">{cards.end}</div>
</div>
) : ( ) : (
<p className="m-0 rounded-2xl border border-dashed border-edge-faint px-4 py-8 text-center text-content-muted" style={fs(13, 'body')}> <div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{t('admin.addons.noMatch')} {groups.map(g => {
</p> const meta = TYPE_META[g.key]
))} return (
</> <section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
<div className="divide-y divide-edge-faint">
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{g.addons.map(tile)}
</div>
</section>
)
})}
</div>
)}
</SettingsCard>
) )
} }
type AddonView = 'all' | 'on' | 'off' /** The head band of one type's column: icon tile, the type, its count and what it means. */
function GroupHead({ icon: Icon, label, hint, addons, t }: {
/** icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
* The head card's body: a tile per type with how many of it are on, then a label: string
* search over the addons' names and descriptions and a switch between all, hint: string
* the ones on and the ones off. addons: Addon[]
*/
function AddonToolbar({ groups, query, onQuery, view, onView, t }: {
groups: { key: string; addons: Addon[] }[]
query: string
onQuery: (q: string) => void
view: AddonView
onView: (v: AddonView) => void
t: (key: string, params?: Record<string, unknown>) => string t: (key: string, params?: Record<string, unknown>) => string
}) { }) {
const enabled = addons.filter(a => a.enabled).length
return ( return (
<> <div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3"> <span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
{groups.map(g => { <Icon size={13} strokeWidth={2} />
const meta = TYPE_META[g.key] </span>
const Icon = meta.icon <div className="min-w-0 flex-1">
const on = g.addons.filter(a => a.enabled).length <div className="flex items-center gap-2">
return ( <h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
<div key={g.key} className="flex items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5"> <Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-secondary text-content-secondary"> <span className="inline-flex">
<Icon size={15} /> <StatusPill>{enabled}/{addons.length}</StatusPill>
</span> </span>
<span className="min-w-0 flex-1"> </Tooltip>
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
<span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
</span>
<span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
</div>
)
})}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="relative flex min-w-0 flex-1 items-center">
<Search size={14} className="pointer-events-none absolute start-3 text-content-faint" />
<input
type="search"
value={query}
onChange={e => onQuery(e.target.value)}
placeholder={t('admin.addons.search')}
aria-label={t('admin.addons.search')}
className="h-9 w-full rounded-[10px] border border-edge-faint bg-surface-card ps-8 pe-3 text-content outline-none transition-colors placeholder:text-content-faint focus:border-accent"
style={fs(13, 'body')}
/>
</label>
<div className="inline-flex flex-none gap-1 rounded-[10px] bg-surface-tertiary p-1" role="group" aria-label={t('admin.addons.filter')}>
{(['all', 'on', 'off'] as const).map(v => (
<button
key={v}
type="button"
onClick={() => onView(v)}
aria-pressed={view === v}
className={`h-7 rounded-[8px] px-3 font-medium transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
style={fs(12.5, 'body')}
>
{t(`admin.addons.filter.${v}`)}
</button>
))}
</div> </div>
{/* Says what the type means: the only place that still explains it. */}
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
</div> </div>
</> </div>
) )
} }
@@ -11,7 +11,7 @@ import {
Trash2, Trash2,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useRef, useState } from 'react'; import { type HTMLAttributes, type MouseEvent, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
@@ -79,7 +79,6 @@ export default function AssistantHeader({
const [memoryOpen, setMemoryOpen] = useState(false); const [memoryOpen, setMemoryOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const menu = useContextMenu(); const menu = useContextMenu();
const moreRef = useRef<HTMLButtonElement>(null);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const threads = thread.threads ?? []; const threads = thread.threads ?? [];
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1); const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
@@ -96,14 +95,6 @@ export default function AssistantHeader({
run(); run();
}; };
// Closed by Escape or a pick while it holds the focus, the menu hands it back to its button, so the keys
// go on reaching the window; a click or a scroll elsewhere leaves the focus where it went.
const closeMenu = () => {
const fromMenu = document.activeElement?.closest('[role="menu"]') != null;
menu.close();
if (fromMenu) moreRef.current?.focus();
};
const openMenu = (e: MouseEvent) => const openMenu = (e: MouseEvent) =>
menu.open( menu.open(
e, e,
@@ -196,12 +187,10 @@ export default function AssistantHeader({
</Tooltip> </Tooltip>
<Tooltip label={t('assistant.window.menu')}> <Tooltip label={t('assistant.window.menu')}>
<button <button
ref={moreRef}
type="button" type="button"
onClick={openMenu} onClick={openMenu}
aria-label={t('assistant.window.menu')} aria-label={t('assistant.window.menu')}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={menu.menu !== null}
className={ICON_BUTTON} className={ICON_BUTTON}
> >
<Ellipsis size={14} aria-hidden="true" /> <Ellipsis size={14} aria-hidden="true" />
@@ -229,7 +218,6 @@ export default function AssistantHeader({
icon={null} icon={null}
label={th.title} label={th.title}
active={th.id === thread.threadId} active={th.id === thread.threadId}
current={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))} onClick={() => pick(() => thread.openThread(th.id))}
trailing={ trailing={
<span className="flex-shrink-0 text-caption text-content-faint"> <span className="flex-shrink-0 text-caption text-content-faint">
@@ -246,7 +234,7 @@ export default function AssistantHeader({
)} )}
</PopoverListPanel> </PopoverListPanel>
<ContextMenu menu={menu.menu} onClose={closeMenu} /> <ContextMenu menu={menu.menu} onClose={menu.close} />
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />} {memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<ConfirmDialog <ConfirmDialog
isOpen={confirming} isOpen={confirming}
@@ -1,7 +1,7 @@
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react'; import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
import type { AssistantMessage as StoredMessage } from '@trek/shared'; import type { AssistantMessage as StoredMessage } from '@trek/shared';
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react'; import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
import { Fragment, 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';
@@ -16,7 +16,7 @@ import AssistantShareDialog from './AssistantShareDialog';
import AssistantSources from './AssistantSources'; import AssistantSources from './AssistantSources';
import AssistantToolSteps from './AssistantToolSteps'; import AssistantToolSteps from './AssistantToolSteps';
import AssistantWeatherStrip from './AssistantWeatherStrip'; import AssistantWeatherStrip from './AssistantWeatherStrip';
import { type MessageBlock, messageBlocks, tailStart } from './assistantParts'; import { messageBlocks } from './assistantParts';
import { useAssistantView } from './assistantViewContext'; import { useAssistantView } from './assistantViewContext';
import AssistantCardBlock from './cards/AssistantCardBlock'; import AssistantCardBlock from './cards/AssistantCardBlock';
import { useAssistantShare } from './useAssistantShare'; import { useAssistantShare } from './useAssistantShare';
@@ -108,28 +108,6 @@ function Question({ message }: { message: StoredMessage }) {
); );
} }
/** One block of an answer, in the shape its kind takes. */
function AnswerBlock({
block,
message,
writing,
streaming,
}: {
block: MessageBlock;
message: StoredMessage;
writing: boolean;
streaming: boolean;
}) {
if (block.kind === 'text') return <AssistantMarkdown text={block.text} writing={streaming} />;
if (block.kind === 'steps') return <AssistantToolSteps steps={block.steps} />;
if (block.kind === 'places') return <AssistantPlaceStrip found={block.found} message={message} />;
if (block.kind === 'weather') return <AssistantWeatherStrip days={block.days} />;
if (block.kind === 'sources') return <AssistantSources sources={block.sources} />;
if (block.kind === 'remembered') return <AssistantRemembered note={block.note} live={writing} />;
if (block.kind === 'cards') return <AssistantCardBlock block={block} message={message} />;
return <AssistantChangeCard items={block.items} />;
}
function Answer({ message }: { message: StoredMessage }) { function Answer({ message }: { message: StoredMessage }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { tripId, variant } = useAssistantView(); const { tripId, variant } = useAssistantView();
@@ -141,16 +119,25 @@ function Answer({ message }: { message: StoredMessage }) {
const share = useAssistantShare(tripId, message, writing); const share = useAssistantShare(tripId, message, writing);
const last = blocks[blocks.length - 1]; const last = blocks[blocks.length - 1];
const hasText = blocks.some((b) => b.kind === 'text'); const hasText = blocks.some((b) => b.kind === 'text');
const tail = tailStart(blocks);
return ( return (
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2"> <MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
{blocks.map((block, i) => ( {blocks.map((block) => {
<Fragment key={block.key}> if (block.kind === 'text') {
{i === tail && i > 0 && <span data-assistant-tail aria-hidden="true" className="-mb-2.5 block h-0" />} return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
<AnswerBlock block={block} message={message} writing={writing} streaming={writing && block === last} /> }
</Fragment> if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
))} if (block.kind === 'places') {
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
}
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
if (block.kind === 'remembered') {
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
}
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
return <AssistantChangeCard key={block.key} items={block.items} />;
})}
{/* Nothing below says the answer goes on: no words being written, no step running. */} {/* Nothing below says the answer goes on: no words being written, no step running. */}
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && ( {writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
<p role="status" className="m-0 text-caption"> <p role="status" className="m-0 text-caption">
@@ -1,5 +1,5 @@
import type { AssistantFoundPlace } from '@trek/shared'; import type { AssistantFoundPlace } from '@trek/shared';
import { Check, MapPin, Plus, Route, Star } from 'lucide-react'; import { Check, MapPin, Plus, Star } from 'lucide-react';
import { createElement, type ReactNode, useRef } from 'react'; import { createElement, type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
@@ -25,24 +25,6 @@ function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip:
); );
} }
/** "+ Tour": takes the place into the saved tour open in the planner as a stop, held as a change in the chat. */
function TourButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
const { t } = useTranslation();
if (!strip.tour) return null;
return (
<Tooltip label={t('assistant.place.addToTourHint', { name: strip.tour.name })} placement="top">
<button
type="button"
onClick={() => strip.addToTour(place)}
className={`${ACTION} ${tall ? 'h-10' : 'h-7'} min-w-0 px-2`}
>
<Route size={12} aria-hidden="true" className="flex-shrink-0" />
<span className="truncate">{t('assistant.place.addToTour')}</span>
</button>
</Tooltip>
);
}
type Category = ReturnType<Strip['categoryOf']>; type Category = ReturnType<Strip['categoryOf']>;
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */ /** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
@@ -203,9 +185,8 @@ export default function AssistantPlaceCard({
)} )}
</span> </span>
</button> </button>
<div className="mt-auto flex min-w-0 gap-1.5 px-3 pb-3 pt-2.5"> <div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} /> <PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
<TourButton place={place} strip={strip} tall={tall} />
</div> </div>
</li> </li>
); );
@@ -21,7 +21,6 @@ import { fetchPhoto } from '../../services/photoService';
import { assistantChatKey } from '../../store/assistantChatKey'; import { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { setOpenTour } from '../../store/openTourStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { setForcedOffline } from '../../sync/networkMode'; import { setForcedOffline } from '../../sync/networkMode';
import AssistantPlaceStrip from './AssistantPlaceStrip'; import AssistantPlaceStrip from './AssistantPlaceStrip';
@@ -106,7 +105,6 @@ afterEach(() => {
useAuthStore.setState(AUTH, true); useAuthStore.setState(AUTH, true);
globalThis.IntersectionObserver = RealObserver; globalThis.IntersectionObserver = RealObserver;
setForcedOffline(false); setForcedOffline(false);
setOpenTour(null);
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -240,22 +238,3 @@ describe('AssistantPlaceStrip', () => {
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
}); });
}); });
describe('"+ Tour" on a found place (P6)', () => {
it('FE-AI-PLACES-011: shows only while a saved tour of this trip is open, and holds the place as its stop', () => {
seed();
const insertFoundPlace = vi.fn(async () => 'held' as const);
useAssistantStore.setState({ insertFoundPlace });
setOpenTour({ tripId: TRIP, placeId: 64, name: 'Herzogstand' });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: 'Add to tour' }));
expect(insertFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 64);
});
it('FE-AI-PLACES-012: a tour open on another trip, or a traveller who may not edit, gets no "+ Tour"', () => {
seed();
setOpenTour({ tripId: TRIP + 1, placeId: 64, name: 'Elsewhere' });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.queryByRole('button', { name: 'Add to tour' })).toBeNull();
});
});
@@ -1,6 +1,6 @@
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react'; import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
import { ArrowDown, FileUp, MapPin } from 'lucide-react'; import { ArrowDown, FileUp, MapPin } from 'lucide-react';
import { useMemo, useRef } 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';
@@ -15,15 +15,13 @@ import { useAssistantAttach } from './useAssistantAttach';
import { useFollowSelectedDay } from './useAssistantFocus'; import { useFollowSelectedDay } from './useAssistantFocus';
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop'; import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
import { useAssistantSuggestions } from './useAssistantSuggestions'; import { useAssistantSuggestions } from './useAssistantSuggestions';
import { useRevealAnswerTail } from './useRevealAnswerTail';
/** /**
* The chat itself: the messages, and under them the line that says what went * The chat itself: the messages, and under them the line that says what went
* wrong, the privacy note and the composer, held at the bottom while the * wrong, the privacy note and the composer, held at the bottom while the
* messages scroll. A new question moves up to the top of the view, so its * messages scroll. A new question moves up to the top of the view, so its
* answer reads from its first line instead of chasing the newest one, and once * answer reads from its first line instead of chasing the newest one. A
* the answer is written its end comes into view. A booking file or a place of * booking file or a place of the trip can be dropped anywhere on it.
* the trip can be dropped anywhere on it.
*/ */
export default function AssistantThread({ tripTitle }: { tripTitle: string }) { export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -33,8 +31,6 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]); const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const { suggestions, canEdit } = useAssistantSuggestions(tripId); const { suggestions, canEdit } = useAssistantSuggestions(tripId);
useFollowSelectedDay(tripId); useFollowSelectedDay(tripId);
const viewport = useRef<HTMLDivElement>(null);
useRevealAnswerTail(viewport, isRunning);
const phone = variant === 'fullscreen'; const phone = variant === 'fullscreen';
const attach = useAssistantAttach(tripId); const attach = useAssistantAttach(tripId);
@@ -56,11 +52,7 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
</span> </span>
</div> </div>
)} )}
<ThreadPrimitive.Viewport <ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
ref={viewport}
turnAnchor="top"
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4"
>
<AuiIf condition={(s) => s.thread.isEmpty}> <AuiIf condition={(s) => s.thread.isEmpty}>
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} /> <AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
{mentionable && ( {mentionable && (
@@ -1,5 +1,4 @@
import { ChevronRight, Luggage } from 'lucide-react'; import { ChevronRight, Luggage } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState'; import EmptyState from '../shared/EmptyState';
@@ -9,17 +8,11 @@ import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicke
/** /**
* The user's trips to go on with in one trip's own chat, from the home chat's * The user's trips to go on with in one trip's own chat, from the home chat's
* head: that chat, its history and its changes belong to the trip. The * 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, * running and the next trip lead.
* so the focus goes onto the first one once they come.
*/ */
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) { export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const trips = useAssistantTripPicker(); const trips = useAssistantTripPicker();
const first = useRef<HTMLButtonElement>(null);
useEffect(() => {
first.current?.focus({ preventScroll: true });
}, [trips]);
if (trips === null) { if (trips === null) {
return ( return (
@@ -39,10 +32,9 @@ export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerT
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2"> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
<ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0"> <ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0">
{trips.map((trip, i) => ( {trips.map((trip) => (
<li key={trip.id}> <li key={trip.id}>
<button <button
ref={i === 0 ? first : undefined}
type="button" type="button"
onClick={() => onPick(trip)} onClick={() => onPick(trip)}
className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover" className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
@@ -1,5 +1,5 @@
/** /**
* FE-AI-WIN-001 to FE-AI-WIN-016 * FE-AI-WIN-001 to FE-AI-WIN-014
* *
* The desktop window around the chat: when it shows at all, the button it * The desktop window around the chat: when it shows at all, the button it
* opens from, the home chat outside a trip with its way into one trip's chat * opens from, the home chat outside a trip with its way into one trip's chat
@@ -238,34 +238,6 @@ describe('AssistantWindow', () => {
expect(useAssistantStore.getState().window.open).toBe(true); expect(useAssistantStore.getState().window.open).toBe(true);
}); });
it("FE-AI-WIN-015: the header's menu says whether it is open, and Escape hands the focus back to its button", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
const more = screen.getByRole('button', { name: 'More options' });
expect(more).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(more);
expect(more).toHaveAttribute('aria-expanded', 'true');
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
expect(more).toHaveFocus();
expect(more).toHaveAttribute('aria-expanded', 'false');
// From the button the next Escape is the window's again.
fireEvent.keyDown(more, { key: 'Escape' });
expect(useAssistantStore.getState().window.open).toBe(false);
});
it("FE-AI-WIN-016: the home chat's trip list takes the focus onto the first trip once the trips are there", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />);
const button = screen.getByRole('button', { name: 'Chat about one trip' });
fireEvent.click(button);
// The list's own row, not the chat's "go on in Lisbon" beside it.
const lisbon = await screen.findByRole('button', { name: /^Lisbon/ });
await waitFor(() => expect(lisbon).toHaveFocus());
fireEvent.keyDown(lisbon, { key: 'Escape' });
expect(button).toHaveFocus();
});
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => { it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
seed({ open: true, minimized: false }); seed({ open: true, minimized: false });
const onFocusDay = vi.fn(); const onFocusDay = vi.fn();
@@ -107,7 +107,6 @@ const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
decide_first: { messageKey: 'assistant.error.decideFirst', action: null }, decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null }, too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null }, no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
no_tracks_found: { messageKey: 'assistant.error.noTracksFound', action: null },
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null }, import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
generic: { generic: {
messageKey: 'assistant.error.generic', messageKey: 'assistant.error.generic',
@@ -61,7 +61,7 @@ export interface ChangeItem {
/** The answer cards of one group, side by side. */ /** The answer cards of one group, side by side. */
export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] }; export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] };
export type MessageBlock = type MessageBlock =
| { kind: 'text'; key: string; text: string } | { kind: 'text'; key: string; text: string }
| { kind: 'steps'; key: string; steps: ToolStep[] } | { kind: 'steps'; key: string; steps: ToolStep[] }
| { kind: 'change'; key: string; items: ChangeItem[] } | { kind: 'change'; key: string; items: ChangeItem[] }
@@ -127,10 +127,6 @@ function namedFirst(places: readonly AssistantFoundPlace[], text: string): Assis
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([ const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'propose_hike', 'propose_hike',
'create_hike', 'create_hike',
'propose_tour',
'create_tour',
'create_paddle_tour',
'create_ski_tour',
'update_tour', 'update_tour',
'propose_city_walk', 'propose_city_walk',
'create_city_walk', 'create_city_walk',
@@ -138,9 +134,6 @@ const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'plan_ski_route', 'plan_ski_route',
'prepare_tour_change', 'prepare_tour_change',
'save_tour_change', 'save_tour_change',
'propose_round_hike',
'propose_hike_leg_way',
'import_wanderer_trail',
]); ]);
/** The tool that keeps a note in the trip notes; kept at once, without a card. */ /** The tool that keeps a note in the trip notes; kept at once, without a card. */
@@ -462,16 +455,4 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
difficulty: { labelKey: 'assistant.change.label.difficulty' }, difficulty: { labelKey: 'assistant.change.label.difficulty' },
track: { labelKey: 'assistant.change.label.track' }, track: { labelKey: 'assistant.change.label.track' },
packing: { labelKey: 'assistant.change.label.packing' }, packing: { labelKey: 'assistant.change.label.packing' },
breaks: { labelKey: 'assistant.change.label.breaks' },
}; };
/**
* Where an answer's closing words and changes begin: the block after its last
* step, strip or card. That end is what the thread brings into view once the
* answer is written.
*/
export function tailStart(blocks: MessageBlock[]): number {
let i = blocks.length;
while (i > 0 && (blocks[i - 1].kind === 'text' || blocks[i - 1].kind === 'change')) i -= 1;
return i;
}
@@ -1,5 +1,5 @@
/** /**
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-017 * FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-016
* *
* The pure side of the book's page card: a page named as the Studio names * The pure side of the book's page card: a page named as the Studio names
* it, a layout and a finding by their words, the findings worth a look, the * it, a layout and a finding by their words, the findings worth a look, the
@@ -8,7 +8,7 @@
* the card carries it and the book is still at the trial's version), and * the card carries it and the book is still at the trial's version), and
* where an applied change opens the Studio. * where an applied change opens the Studio.
*/ */
import { BOOK_FINDING_CODES, blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared'; import { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
@@ -67,9 +67,6 @@ describe('bookCardModel', () => {
it('FE-AI-BOOKPAGE-011: a layout is named by its template, its grid, its own name or as laid out again', () => { it('FE-AI-BOOKPAGE-011: a layout is named by its template, its grid, its own name or as laid out again', () => {
expect(layoutLabel({ kind: 'template', id: 'two-up', name: null }, t)).toBe('journey.studio.tpl.twoUp'); expect(layoutLabel({ kind: 'template', id: 'two-up', name: null }, t)).toBe('journey.studio.tpl.twoUp');
expect(layoutLabel({ kind: 'template', id: 'cover-full', name: null }, t)).toBe('journey.studio.tpl.coverFull'); expect(layoutLabel({ kind: 'template', id: 'cover-full', name: null }, t)).toBe('journey.studio.tpl.coverFull');
expect(layoutLabel({ kind: 'template', id: 'postcard-houses', name: null }, t)).toBe(
'studio.templates.name.postcardHouses'
);
expect(layoutLabel({ kind: 'grid', id: 'two-across', name: null }, t)).toBe('assistant.card.book.grid {"count":2}'); expect(layoutLabel({ kind: 'grid', id: 'two-across', name: null }, t)).toBe('assistant.card.book.grid {"count":2}');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: 'Mine' }, t)).toBe('Mine'); expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: 'Mine' }, t)).toBe('Mine');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1'); expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1');
@@ -95,15 +92,6 @@ describe('bookCardModel', () => {
expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi'); expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi');
}); });
it('FE-AI-BOOKPAGE-017: every finding the checks answer with has its words, the art checks among them', () => {
for (const code of BOOK_FINDING_CODES) {
expect(findingLabel({ code, severity: 'info' }, t)).toBe(`assistant.card.book.finding.${code}`);
}
for (const code of ['flat_cover', 'weak_hierarchy', 'off_theme', 'energy_mismatch'] as const) {
expect(BOOK_FINDING_CODES).toContain(code);
}
});
it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => { it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => {
const doc = book(); const doc = book();
const [edit] = pageViewOf(card(), doc, 'pending'); const [edit] = pageViewOf(card(), doc, 'pending');
@@ -10,7 +10,6 @@ import {
type BookSpread, type BookSpread,
findBookGrid, findBookGrid,
findBookTemplate, findBookTemplate,
findDecoratedTemplate,
setBookCover, setBookCover,
} from '@trek/shared'; } from '@trek/shared';
@@ -58,15 +57,6 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
font_mix: { labelKey: 'assistant.card.book.finding.font_mix' }, font_mix: { labelKey: 'assistant.card.book.finding.font_mix' },
palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' }, palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' },
repeat_layout: { labelKey: 'assistant.card.book.finding.repeat_layout' }, repeat_layout: { labelKey: 'assistant.card.book.finding.repeat_layout' },
raster_effect: { labelKey: 'assistant.card.book.finding.raster_effect' },
hairline: { labelKey: 'assistant.card.book.finding.hairline' },
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' },
flat_cover: { labelKey: 'assistant.card.book.finding.flat_cover' },
weak_hierarchy: { labelKey: 'assistant.card.book.finding.weak_hierarchy' },
off_theme: { labelKey: 'assistant.card.book.finding.off_theme' },
energy_mismatch: { labelKey: 'assistant.card.book.finding.energy_mismatch' },
}; };
/** A page as the Studio names it: the cover, the first page, double page 3. */ /** A page as the Studio names it: the cover, the first page, double page 3. */
@@ -92,7 +82,7 @@ export function layoutLabel(layout: BookCardLayout | null, t: Translate): string
const grid = findBookGrid(layout.id); const grid = findBookGrid(layout.id);
return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null; return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null;
} }
const template = findBookTemplate(layout.id) ?? findDecoratedTemplate(layout.id); const template = findBookTemplate(layout.id);
return template ? t(template.labelKey) : null; return template ? t(template.labelKey) : null;
} }
@@ -37,7 +37,6 @@ export const BOOK_LOOK = {
// The server's own read: it waits for the Studio to draw the page, and says it looks at it. // The server's own read: it waits for the Studio to draw the page, and says it looks at it.
preview_book_page: { stepKey: 'assistant.step.lookingAtPage' }, preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' }, list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
list_book_assets: { stepKey: 'assistant.step.bookAssets' },
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' }, suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
}, },
entities: { entities: {
@@ -1,4 +1,4 @@
import { BookmarkMinus, BookmarkPlus, Copy, Palette, Ruler, Sparkles } from 'lucide-react'; import { BookmarkMinus, BookmarkPlus, Copy, Ruler, Sparkles } from 'lucide-react';
import type { PackLook } from '../cardTypes'; import type { PackLook } from '../cardTypes';
@@ -19,7 +19,6 @@ export const BOOK_DESIGN_LOOK = {
verbs: { verbs: {
designBook: { Icon: Sparkles, tone: 'info', labelKey: 'assistant.change.verb.designBook' }, designBook: { Icon: Sparkles, tone: 'info', labelKey: 'assistant.change.verb.designBook' },
setBookSetup: { Icon: Ruler, tone: 'info', labelKey: 'assistant.change.verb.setBookSetup' }, setBookSetup: { Icon: Ruler, tone: 'info', labelKey: 'assistant.change.verb.setBookSetup' },
setBookTheme: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setBookTheme' },
duplicateBookPage: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.duplicateBookPage' }, duplicateBookPage: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.duplicateBookPage' },
saveBookLayout: { Icon: BookmarkPlus, tone: 'success', labelKey: 'assistant.change.verb.saveBookLayout' }, saveBookLayout: { Icon: BookmarkPlus, tone: 'success', labelKey: 'assistant.change.verb.saveBookLayout' },
removeBookLayout: { Icon: BookmarkMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeBookLayout' }, removeBookLayout: { Icon: BookmarkMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeBookLayout' },
@@ -24,19 +24,6 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
journal: { labelKey: 'assistant.card.bookDesign.style.journal' }, journal: { labelKey: 'assistant.card.bookDesign.style.journal' },
bold: { labelKey: 'assistant.card.bookDesign.style.bold' }, bold: { labelKey: 'assistant.card.bookDesign.style.bold' },
scrapbook: { labelKey: 'assistant.card.bookDesign.style.scrapbook' }, scrapbook: { labelKey: 'assistant.card.bookDesign.style.scrapbook' },
// A design in one of the themes is called by the theme's own name.
'poster-pop': { labelKey: 'studio.theme.name.posterPop' },
cutout: { labelKey: 'studio.theme.name.cutout' },
postcard: { labelKey: 'studio.theme.name.postcard' },
riviera: { labelKey: 'studio.theme.name.riviera' },
'fabric-classic': { labelKey: 'studio.theme.name.fabricClassic' },
'minimal-serif': { labelKey: 'studio.theme.name.minimalSerif' },
'grid-badge': { labelKey: 'studio.theme.name.gridBadge' },
'scrapbook-script': { labelKey: 'studio.theme.name.scrapbookScript' },
tropical: { labelKey: 'studio.theme.name.tropical' },
nordic: { labelKey: 'studio.theme.name.nordic' },
alpine: { labelKey: 'studio.theme.name.alpine' },
'india-stamp': { labelKey: 'studio.theme.name.indiaStamp' },
}; };
type Translate = (key: string, params?: Record<string, string | number>) => string; type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -39,8 +39,7 @@ describe('TripChatBlock', () => {
expect(screen.getByRole('region', { name: 'Continue in a trip chat' })).toBeInTheDocument(); expect(screen.getByRole('region', { name: 'Continue in a trip chat' })).toBeInTheDocument();
expect(screen.getByText('Kyoto')).toBeInTheDocument(); expect(screen.getByText('Kyoto')).toBeInTheDocument();
expect(screen.getByText('Trip chat')).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(/ to /)).toBeInTheDocument();
expect(screen.getByText(/\d to \w/)).toBeInTheDocument();
expect(screen.getByText('Next')).toBeInTheDocument(); expect(screen.getByText('Next')).toBeInTheDocument();
expect(screen.getByText('12 places')).toBeInTheDocument(); expect(screen.getByText('12 places')).toBeInTheDocument();
expect(screen.getByText('What is still missing?')).toBeInTheDocument(); expect(screen.getByText('What is still missing?')).toBeInTheDocument();
@@ -20,16 +20,7 @@ import { ROW_GLYPH, type RowChange } from './checklistModel';
* list, and the rows of a run of changes with their include choice. * list, and the rows of a run of changes with their include choice.
*/ */
/** The band behind a group's head: its colour faint over the card's grey, the grey alone without one. */ /** A group's head: its colour, its name, a badge, and who looks after it (the ones a change drops faded). */
function bandOf(color: string): string {
return color ? `color-mix(in srgb, ${color} 12%, var(--bg-secondary))` : 'var(--bg-secondary)';
}
/**
* A group's head as a section of the list: a full width band in its colour,
* the colour dot, its name, and at the end its badge (how many it holds) and
* who looks after it, the ones a change drops faded.
*/
export function ChecklistHead({ export function ChecklistHead({
color, color,
name, name,
@@ -47,17 +38,14 @@ export function ChecklistHead({
lead?: ReactNode; lead?: ReactNode;
}) { }) {
return ( return (
<div <div className="flex min-w-0 items-center gap-2 pt-1">
className="mt-1.5 flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5"
style={{ background: bandOf(color) }}
>
{lead ?? <CategoryDot color={color} />} {lead ?? <CategoryDot color={color} />}
<bdi title={name} className="min-w-0 flex-1 truncate text-caption font-semibold text-content"> <bdi title={name} className="min-w-0 truncate text-caption font-semibold text-content">
{name} {name}
</bdi> </bdi>
{(badge || people.length > 0 || removed.length > 0) && ( {badge}
<span className="flex flex-none items-center gap-1.5"> {(people.length > 0 || removed.length > 0) && (
{badge} <span className="ms-auto flex flex-none items-center gap-1.5">
<PeopleStack people={people} /> <PeopleStack people={people} />
{/* The people a change takes off, faded; the card says "No longer" in words under the head. */} {/* The people a change takes off, faded; the card says "No longer" in words under the head. */}
{removed.length > 0 && ( {removed.length > 0 && (
@@ -58,13 +58,13 @@ function BagSwatch({ bag, size = 'row' }: { bag: PackingCardBag | undefined; siz
/** A row's quantity and weight: quiet figures at its end, no frame each. */ /** A row's quantity and weight: quiet figures at its end, no frame each. */
const QUIET = 'text-caption tabular-nums text-content-muted'; const QUIET = 'text-caption tabular-nums text-content-muted';
/** How many rows a group holds, a white pill at the end of its band. */ /** How many rows a group holds, quiet beside its name. */
function GroupCount({ count }: { count: number }) { function GroupCount({ count }: { count: number }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<span <span
aria-label={t('tours.packing.items', { count })} aria-label={t('tours.packing.items', { count })}
className="inline-flex h-[18px] flex-none items-center rounded-full bg-surface-card px-1.5 text-caption font-semibold tabular-nums text-content-secondary shadow-sm" className="flex-none text-caption tabular-nums text-content-faint"
> >
{count} {count}
</span> </span>
@@ -231,7 +231,7 @@ function BagHead({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (!bag) { if (!bag) {
const badge = <GroupCount count={group.count} />; const badge = inChange ? undefined : <GroupCount count={group.count} />;
const lead = <BagSwatch bag={undefined} size="head" />; const lead = <BagSwatch bag={undefined} size="head" />;
return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />; return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />;
} }
@@ -298,7 +298,7 @@ export function PackingGroup({
<ChecklistHead <ChecklistHead
color={packingColorOf(key.slot, name)} color={packingColorOf(key.slot, name)}
name={name} name={name}
badge={<GroupCount count={group.count} />} badge={inChange ? undefined : <GroupCount count={group.count} />}
people={group.assignees} people={group.assignees}
removed={group.assigneesRemoved} removed={group.assigneesRemoved}
/> />
@@ -5,7 +5,6 @@ import { useTranslation } from '../../../../i18n';
import { useSettingsStore } from '../../../../store/settingsStore'; import { useSettingsStore } from '../../../../store/settingsStore';
import { formatClockTime, formatDate, formatMoney } from '../../../../utils/formatters'; import { formatClockTime, formatDate, formatMoney } from '../../../../utils/formatters';
import { formatDistance, formatElevation } from '../../../../utils/units'; import { formatDistance, formatElevation } from '../../../../utils/units';
import { formatRunPace, runPaceUnitText } from '../../../Tours/run/runPace';
/** /**
* How a card writes its numbers, the way the rest of TREK does for this * How a card writes its numbers, the way the rest of TREK does for this
@@ -14,7 +13,7 @@ import { formatRunPace, runPaceUnitText } from '../../../Tours/run/runPace';
* units. A card hands these to its words as `{value}`, never a raw number. * units. A card hands these to its words as `{value}`, never a raw number.
*/ */
export function useCardFormat() { export function useCardFormat() {
const { locale, t } = useTranslation(); const { locale } = useTranslation();
const twelveHour = useSettingsStore((s) => s.settings.time_format === '12h'); const twelveHour = useSettingsStore((s) => s.settings.time_format === '12h');
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric'); const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
return useMemo(() => { return useMemo(() => {
@@ -38,8 +37,6 @@ export function useCardFormat() {
return m === 0 ? hours.format(h) : `${hours.format(h)} ${mins.format(m)}`; return m === 0 ? hours.format(h) : `${hours.format(h)} ${mins.format(m)}`;
}, },
count: (value: number) => number.format(value), count: (value: number) => number.format(value),
/** "5:30 min/km" from seconds per kilometre, per mile with distances in miles. */
pace: (secondsPerKm: number) => `${formatRunPace(secondsPerKm, unit)} ${runPaceUnitText(unit, t)}`,
}; };
}, [locale, twelveHour, unit, t]); }, [locale, twelveHour, unit]);
} }
@@ -1,5 +1,5 @@
/** /**
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042, FE-AI-TOUR-061 to FE-AI-TOUR-062 * FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042
* *
* A route in the chat as the Tours tab shows a hike: its figures and grade, * A route in the chat as the Tours tab shows a hike: its figures and grade,
* a profile only where the heights are known, the profile, the stop chips * a profile only where the heights are known, the profile, the stop chips
@@ -387,44 +387,4 @@ describe('TourRouteCard', () => {
expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2); expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
expect(screen.getAllByText('Start by 10:30')).toHaveLength(1); expect(screen.getAllByText('Start by 10:30')).toHaveLength(1);
}); });
it('FE-AI-TOUR-061: a bike tour names its profile and its riding time, a run its pace, and waits for one it lacks', () => {
const bike: Route = {
...HIKE,
activity: 'bike',
routingProfile: 'gravel',
minutesBasis: 'router',
limit: null,
grades: null,
};
const { container } = renderCard(bike);
expect(screen.getByText('Gravel')).toBeInTheDocument();
expect(screen.queryByTestId('tour-grade')).toBeNull();
expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled();
expect(container.querySelector('[data-field="pace"]')).toBeNull();
const run: Route = {
...bike,
activity: 'run',
routingProfile: 'trail',
minutesBasis: 'pace',
paceSecondsPerKm: 330,
};
const paced = renderCard(run);
expect(screen.getByText('Trail running')).toBeInTheDocument();
expect(paced.container.querySelector('[data-field="pace"]')).toHaveTextContent('5:30 min/km');
renderCard({ ...run, paceSecondsPerKm: null, minutes: null });
expect(screen.getByText('Tell the assistant your pace to save this run.')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Plan this' });
expect(buttons[buttons.length - 1]).toBeDisabled();
});
it('FE-AI-TOUR-062: a whole paddle plan offers "Plan this"; one with a leg that failed does not', () => {
const plan: Route = { ...HIKE, activity: 'kayak', source: 'plan', limit: null, grades: null, schedule: null };
renderCard(plan);
expect(screen.getByRole('button', { name: 'Plan this' })).toBeInTheDocument();
renderCard({ ...plan, name: 'Broken', warnings: [{ kind: 'legs_failed', at: null, count: 1 }] });
expect(screen.getAllByRole('button', { name: 'Plan this' })).toHaveLength(1);
});
}); });
@@ -16,7 +16,7 @@ import RouteSketch from '../parts/RouteSketch';
import { useCardFormat } from '../parts/useCardFormat'; import { useCardFormat } from '../parts/useCardFormat';
import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel'; import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel'; import { sketchOf } from './tourRouteSketchModel';
import { PROFILE_WORDS, routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords'; import { routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
/** /**
* The head of a route card: the route drawn small beside its name, the * The head of a route card: the route drawn small beside its name, the
@@ -63,16 +63,14 @@ export function TourRouteFigures({ card }: { card: TourRouteCard }) {
/** /**
* The grade of a hike, as the hike's own card shows it: the hardest SAC step * The grade of a hike, as the hike's own card shows it: the hardest SAC step
* along the way, uncertain when too little of it is graded, with what it * along the way, uncertain when too little of it is graded, with what it
* means, the routing limit and where grades come from in its tooltip. A bike * means, the routing limit and where grades come from in its tooltip. Any
* tour or a run names its profile instead, any other tour what kind it is. * other tour says what kind it is instead.
*/ */
export function TourGrade({ card }: { card: TourRouteCard }) { export function TourGrade({ card }: { card: TourRouteCard }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (card.activity !== 'hike' || !card.grades) { if (card.activity !== 'hike' || !card.grades) {
const icon = createElement(tourDayRowIcon(card.activity), { size: 10, strokeWidth: 2.2, 'aria-hidden': true }); const icon = createElement(tourDayRowIcon(card.activity), { size: 10, strokeWidth: 2.2, 'aria-hidden': true });
// A bike tour or a run says what it was planned for: "Gravel", "Trail running". return <SoftPill icon={icon}>{t(TOUR_TYPE_WORDS[card.activity].labelKey)}</SoftPill>;
const words = card.routingProfile ? PROFILE_WORDS[card.routingProfile] : TOUR_TYPE_WORDS[card.activity];
return <SoftPill icon={icon}>{t(words.labelKey)}</SoftPill>;
} }
const { highest, unknownShare } = card.grades; const { highest, unknownShare } = card.grades;
const uncertain = unknownShare >= 0.5; const uncertain = unknownShare >= 0.5;
@@ -140,7 +138,7 @@ export function TourRouteSketch({
export function TourRouteFields({ card }: { card: TourRouteCard }) { export function TourRouteFields({ card }: { card: TourRouteCard }) {
const { t } = useTranslation(); const { t } = useTranslation();
const format = useCardFormat(); const format = useCardFormat();
const fields: Array<{ key: string; label: string; value: string }> = factKeysOf(card).flatMap((key) => { const fields = factKeysOf(card).flatMap((key) => {
const value = factValue(card, key); const value = factValue(card, key);
if (value === null) return []; if (value === null) return [];
// A field's label is short; how the time was reckoned is the figure badges' tooltip. // A field's label is short; how the time was reckoned is the figure badges' tooltip.
@@ -150,10 +148,6 @@ export function TourRouteFields({ card }: { card: TourRouteCard }) {
else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value); else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
return [{ key, label, value: text }]; return [{ key, label, value: text }];
}); });
// A run's time is its distance at the runner's pace: the pace stands beside it.
if (card.paceSecondsPerKm != null) {
fields.push({ key: 'pace', label: t('tours.run.pace'), value: format.pace(card.paceSecondsPerKm) });
}
return <CardFields fields={fields} />; return <CardFields fields={fields} />;
} }
@@ -179,9 +173,9 @@ export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; o
title={name} title={name}
meta={card.label ?? undefined} meta={card.label ?? undefined}
> >
{((card.activity === 'hike' && card.grades) || card.routingProfile || card.schedule) && ( {((card.activity === 'hike' && card.grades) || card.schedule) && (
<div className="mt-2.5 flex flex-wrap items-center gap-1.5"> <div className="mt-2.5 flex flex-wrap items-center gap-1.5">
{((card.activity === 'hike' && card.grades) || card.routingProfile) && <TourGrade card={card} />} {card.activity === 'hike' && card.grades && <TourGrade card={card} />}
<DayPill card={card} /> <DayPill card={card} />
</div> </div>
)} )}
@@ -1,5 +1,5 @@
/** /**
* FE-AI-TOUR-033 to FE-AI-TOUR-034, FE-AI-TOUR-063 * FE-AI-TOUR-033 to FE-AI-TOUR-034
* *
* A held tour change in its row: the route drawn small with its figures and * A held tour change in its row: the route drawn small with its figures and
* grade and a small profile, the kind of tour as the row's tile; a tour whose * grade and a small profile, the kind of tour as the row's tile; a tour whose
@@ -71,10 +71,4 @@ describe('TourRouteInRow', () => {
expect(delta).toHaveTextContent('+1.6 km'); expect(delta).toHaveTextContent('+1.6 km');
expect(delta).toHaveTextContent('+20 min'); expect(delta).toHaveTextContent('+20 min');
}); });
it('FE-AI-TOUR-063: a held bike tour or run names its profile in its row, never a hike grade', () => {
renderRow({ ...PENDING, activity: 'bike', routingProfile: 'road', grades: null, limit: null });
expect(screen.getByText('Road')).toBeInTheDocument();
expect(screen.queryByTestId('tour-grade')).toBeNull();
});
}); });
@@ -53,7 +53,7 @@ export default function TourRouteInRow({ card, item }: { card: TourRouteCard; it
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<WayFigures way={legVariantOf(card, 'current')} unit={unit} /> <WayFigures way={legVariantOf(card, 'current')} unit={unit} />
</span> </span>
{((grades && grades.highest !== null) || card.routingProfile) && <TourGrade card={card} />} {grades && grades.highest !== null && <TourGrade card={card} />}
</span> </span>
{previous && ( {previous && (
<VariantDelta <VariantDelta
@@ -18,7 +18,6 @@ import CardHeader from '../parts/CardHeader';
import { CardSection } from '../parts/CardSection'; import { CardSection } from '../parts/CardSection';
import WayFigures from './TourFigureLine'; import WayFigures from './TourFigureLine';
import { TourRouteBody } from './TourRouteCard'; import { TourRouteBody } from './TourRouteCard';
import { TourGrade } from './TourRouteHead';
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel'; import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords'; import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords';
import { useLineHover, useTourRouteCard } from './useTourRouteCard'; import { useLineHover, useTourRouteCard } from './useTourRouteCard';
@@ -115,8 +114,6 @@ function WayOption({
{grades && grades.highest !== null && ( {grades && grades.highest !== null && (
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} /> <SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />
)} )}
{/* Ways of a ride or a run may differ by profile: each says its own. */}
{entry.card.routingProfile && <TourGrade card={entry.card} />}
</span> </span>
<WayFigures way={way} unit={unit} /> <WayFigures way={way} unit={unit} />
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />} {!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />}
@@ -1,23 +1,5 @@
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared'; import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
import { import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
ArrowLeftRight,
ChartNoAxesColumn,
Circle,
CircleCheck,
CircleSlash,
Copy,
Footprints,
History,
Luggage,
MapPinMinus,
MapPinPlus,
Palette,
Repeat,
Route,
Snowflake,
TriangleAlert,
Waves,
} from 'lucide-react';
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap'; import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
import type { EntityTarget, PackLook } from '../cardTypes'; import type { EntityTarget, PackLook } from '../cardTypes';
@@ -113,21 +95,10 @@ export const TOUR_LOOK = {
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' }, countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' }, linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' }, unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' },
reverseTour: { Icon: ArrowLeftRight, tone: 'info', labelKey: 'assistant.change.verb.reverseTour' },
closeLoop: { Icon: Repeat, tone: 'info', labelKey: 'assistant.change.verb.closeLoop' },
copyTour: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.copyTour' },
setRouteColor: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setRouteColor' },
classifySki: { Icon: Snowflake, tone: 'info', labelKey: 'assistant.change.verb.classifySki' },
setPaddlePlan: { Icon: Waves, tone: 'info', labelKey: 'assistant.change.verb.setPaddlePlan' },
decideObstacles: { Icon: TriangleAlert, tone: 'info', labelKey: 'assistant.change.verb.decideObstacles' },
restoreTrack: { Icon: History, tone: 'info', labelKey: 'assistant.change.verb.restoreTrack' },
addTourStop: { Icon: MapPinPlus, tone: 'success', labelKey: 'assistant.change.verb.addTourStop' },
removeTourStop: { Icon: MapPinMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeTourStop' },
}, },
tools: {}, tools: {},
steps: { steps: {
propose_city_walk: { stepKey: 'assistant.step.planningWalk' }, propose_city_walk: { stepKey: 'assistant.step.planningWalk' },
propose_tour: { stepKey: 'assistant.step.planningRoute' },
plan_paddle_route: { stepKey: 'assistant.step.planningRoute' }, plan_paddle_route: { stepKey: 'assistant.step.planningRoute' },
plan_ski_route: { stepKey: 'assistant.step.planningRoute' }, plan_ski_route: { stepKey: 'assistant.step.planningRoute' },
get_tour_plan: { stepKey: 'assistant.step.readingTour' }, get_tour_plan: { stepKey: 'assistant.step.readingTour' },
@@ -135,14 +106,6 @@ export const TOUR_LOOK = {
find_tours_for_day: { stepKey: 'assistant.step.readingTours' }, find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
search_tour_pois: { stepKey: 'assistant.step.tourPois' }, search_tour_pois: { stepKey: 'assistant.step.tourPois' },
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' }, get_city_walk_progress: { stepKey: 'assistant.step.readingTour' },
get_tour_trail_details: { stepKey: 'assistant.step.readingTour' },
propose_round_hike: { stepKey: 'assistant.step.findingRounds' },
propose_hike_leg_way: { stepKey: 'assistant.step.findingWays' },
search_wanderer_trails: { stepKey: 'assistant.step.searchingWanderer' },
preview_wanderer_trail: { stepKey: 'assistant.step.readingTrail' },
check_tour_source: { stepKey: 'assistant.step.checkingSource' },
// P7: a route file attached in the chat, read by the server into its tracks.
read_tour_file: { stepKey: 'assistant.step.readingTourFile' },
}, },
entities: { entities: {
tour: { tour: {

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