Compare commits

..

4 Commits

Author SHA1 Message Date
Maurice 2c065356dc test(studio): drive the travel inspector's type through the typeface list and the weight slider 2026-10-23 20:40:40 +02:00
Maurice e0cca62347 fix(studio): declare Big Shoulders Display's bare style imports, and let a raced Studio save carry its revision 2026-10-23 20:10:31 +02:00
Maurice 1222fb3901 fix(studio): import Big Shoulders Display by its export name, which takes no .css 2026-10-23 20:10:30 +02:00
Maurice 142aa61295 feat(studio): 34 typefaces with real weights and italics, a categorised font picker
The book catalogue grows from 7 families to 34, all self-hosted through
@fontsource under the SIL OFL 1.1 or the Apache License 2.0: condensed and
poster faces, heavy retro display, elegant display serifs, Montserrat, twelve
scripts and DM Mono. Weights run from 100 to 900, each family offering only
the weights it ships, and the serifs gain real italics.

The catalogue lives in @trek/shared (book-fonts.data.ts) with each family's
roles (display, heading, body, script, mono), weights, italics and letter
width, so the Studio, the findings, the design engine and the assistant read
one list. Page operations and the design engine set a weight a family lacks
at its nearest one, describe_book_page names the role and the weight, and
font_mix no longer counts scripts and mono faces, warning above three.

The Studio gets StudioFontPicker: the current family set in itself, a
searchable list grouped by kind with every family in its own face and the
book's own families first, and a weight slider over the weights the family
has. Faces load only when used; the assistant's page snapshot embeds only the
faces a page draws, the service worker installs only the latin files of the
original seven families, and the faces of an opened book are cached for
offline use. BOOK_SCHEMA_REV goes to 2.
2026-10-23 20:10:30 +02:00
218 changed files with 4125 additions and 4062 deletions
+51
View File
@@ -53,3 +53,54 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
`server/assets/airports.json` is built from **OurAirports**
(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 |
+67
View File
@@ -0,0 +1,67 @@
import { BOOK_FONT_FACES, BOOK_FONTS_PRECACHED } from '@trek/shared';
/**
* Which font files the service worker installs with the app.
*
* The precache takes every woff and woff2 the build emits, and with the book's
* catalogue at thirty four families that is every weight, both styles and
* every script subset of each: megabytes fetched at install by every user,
* most of it for faces no book of theirs is set in. So the book's faces are
* held to this:
*
* - The faces the app itself draws (Poppins and MuseoModerno for the chrome,
* imported in src/main.tsx) stay exactly as they were.
* - The faces the book shipped with before the catalogue grew
* (`BOOK_FONTS_PRECACHED`) keep their latin woff2, so a book set in them
* still opens offline before Studio was ever opened online.
* - Every other file of a book family, woff, other subsets, new families,
* real italics, is left to the `book-fonts` runtime cache in vite.config.js,
* which the Studio fills with the faces of each book it opens
* (src/components/Studio/bookFontWarm.ts).
*
* Shared by vite.config.js and its test, which reads the filter against file
* names as the build writes them.
*/
/** The faces src/main.tsx imports for the app chrome, which the book shares; bookFontPrecache.test.ts checks this copy. */
export const APP_FACES = {
poppins: [300, 400, 500, 600, 700],
museomoderno: [400, 700, 800],
};
/** The book packages, longest name first, so `dm-serif-display` is never read as `dm-…`. */
const BOOK_PACKAGES = [...new Set(Object.values(BOOK_FONT_FACES).map((f) => f.pkg))].sort(
(a, b) => b.length - a.length
);
const PRECACHED = new Map(
Object.entries(BOOK_FONTS_PRECACHED).map(([id, weights]) => [BOOK_FONT_FACES[id].pkg, new Set(weights)])
);
/**
* A font file as the build names it, `<package>-<subset>-<weight>-<style>-<hash>.<ext>`,
* read against the book's packages; null for any other file.
*/
export function bookFontFile(url) {
const name = url.split('/').pop() ?? '';
const match = /^(.+)-(\d{3})-(normal|italic)-[\w-]+\.(woff2?)$/.exec(name);
if (!match) return null;
const [, head, weight, style, ext] = match;
const pkg = BOOK_PACKAGES.find((p) => head.startsWith(`${p}-`));
if (!pkg) return null;
return { pkg, subset: head.slice(pkg.length + 1), weight: Number(weight), italic: style === 'italic', ext };
}
/** Whether a file of the build goes into the install-time precache. */
export function keepInPrecache(url) {
const file = bookFontFile(url);
if (!file) return true;
if (!file.italic && APP_FACES[file.pkg]?.includes(file.weight)) return true;
const kept = PRECACHED.get(file.pkg);
return !file.italic && file.subset === 'latin' && file.ext === 'woff2' && !!kept?.has(file.weight);
}
/** The precache manifest without the book faces left to the runtime cache: a Workbox `manifestTransforms` entry. */
export function withoutRuntimeFonts(entries) {
return { manifest: entries.filter((entry) => keepInPrecache(entry.url)), warnings: [] };
}
+27
View File
@@ -47,14 +47,41 @@
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
"@fontsource/alfa-slab-one": "^5.3.0",
"@fontsource/allura": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/big-shoulders-display": "^5.3.0",
"@fontsource/bodoni-moda": "^5.3.0",
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/caveat": "^5.3.0",
"@fontsource/cormorant-garamond": "^5.3.0",
"@fontsource/dancing-script": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0",
"@fontsource/dm-serif-display": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/homemade-apple": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/oswald": "^5.3.0",
"@fontsource/pacifico": "^5.3.0",
"@fontsource/parisienne": "^5.3.0",
"@fontsource/permanent-marker": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@fontsource/sacramento": "^5.3.0",
"@fontsource/satisfy": "^5.3.0",
"@fontsource/shrikhand": "^5.3.0",
"@fontsource/yellowtail": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
+1 -1
View File
@@ -26,5 +26,5 @@
"src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 2087
"src/styles/studio.css": 2057
}
@@ -127,10 +127,6 @@ function namedFirst(places: readonly AssistantFoundPlace[], text: string): Assis
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'propose_hike',
'create_hike',
'propose_tour',
'create_tour',
'create_paddle_tour',
'create_ski_tour',
'update_tour',
'propose_city_walk',
'create_city_walk',
@@ -5,7 +5,6 @@ import { useTranslation } from '../../../../i18n';
import { useSettingsStore } from '../../../../store/settingsStore';
import { formatClockTime, formatDate, formatMoney } from '../../../../utils/formatters';
import { formatDistance, formatElevation } from '../../../../utils/units';
import { formatRunPace, runPaceUnitText } from '../../../Tours/run/runPace';
/**
* How a card writes its numbers, the way the rest of TREK does for this
@@ -14,7 +13,7 @@ import { formatRunPace, runPaceUnitText } from '../../../Tours/run/runPace';
* units. A card hands these to its words as `{value}`, never a raw number.
*/
export function useCardFormat() {
const { locale, t } = useTranslation();
const { locale } = useTranslation();
const twelveHour = useSettingsStore((s) => s.settings.time_format === '12h');
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
return useMemo(() => {
@@ -38,8 +37,6 @@ export function useCardFormat() {
return m === 0 ? hours.format(h) : `${hours.format(h)} ${mins.format(m)}`;
},
count: (value: number) => number.format(value),
/** "5:30 min/km" from seconds per kilometre, per mile with distances in miles. */
pace: (secondsPerKm: number) => `${formatRunPace(secondsPerKm, unit)} ${runPaceUnitText(unit, t)}`,
};
}, [locale, twelveHour, unit, t]);
}, [locale, twelveHour, unit]);
}
@@ -1,5 +1,5 @@
/**
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042, FE-AI-TOUR-061 to FE-AI-TOUR-062
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042
*
* A route in the chat as the Tours tab shows a hike: its figures and grade,
* a profile only where the heights are known, the profile, the stop chips
@@ -387,44 +387,4 @@ describe('TourRouteCard', () => {
expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
expect(screen.getAllByText('Start by 10:30')).toHaveLength(1);
});
it('FE-AI-TOUR-061: a bike tour names its profile and its riding time, a run its pace, and waits for one it lacks', () => {
const bike: Route = {
...HIKE,
activity: 'bike',
routingProfile: 'gravel',
minutesBasis: 'router',
limit: null,
grades: null,
};
const { container } = renderCard(bike);
expect(screen.getByText('Gravel')).toBeInTheDocument();
expect(screen.queryByTestId('tour-grade')).toBeNull();
expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled();
expect(container.querySelector('[data-field="pace"]')).toBeNull();
const run: Route = {
...bike,
activity: 'run',
routingProfile: 'trail',
minutesBasis: 'pace',
paceSecondsPerKm: 330,
};
const paced = renderCard(run);
expect(screen.getByText('Trail running')).toBeInTheDocument();
expect(paced.container.querySelector('[data-field="pace"]')).toHaveTextContent('5:30 min/km');
renderCard({ ...run, paceSecondsPerKm: null, minutes: null });
expect(screen.getByText('Tell the assistant your pace to save this run.')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Plan this' });
expect(buttons[buttons.length - 1]).toBeDisabled();
});
it('FE-AI-TOUR-062: a whole paddle plan offers "Plan this"; one with a leg that failed does not', () => {
const plan: Route = { ...HIKE, activity: 'kayak', source: 'plan', limit: null, grades: null, schedule: null };
renderCard(plan);
expect(screen.getByRole('button', { name: 'Plan this' })).toBeInTheDocument();
renderCard({ ...plan, name: 'Broken', warnings: [{ kind: 'legs_failed', at: null, count: 1 }] });
expect(screen.getAllByRole('button', { name: 'Plan this' })).toHaveLength(1);
});
});
@@ -16,7 +16,7 @@ import RouteSketch from '../parts/RouteSketch';
import { useCardFormat } from '../parts/useCardFormat';
import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel';
import { PROFILE_WORDS, routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
import { routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
/**
* The head of a route card: the route drawn small beside its name, the
@@ -63,16 +63,14 @@ export function TourRouteFigures({ card }: { card: TourRouteCard }) {
/**
* The grade of a hike, as the hike's own card shows it: the hardest SAC step
* along the way, uncertain when too little of it is graded, with what it
* means, the routing limit and where grades come from in its tooltip. A bike
* tour or a run names its profile instead, any other tour what kind it is.
* means, the routing limit and where grades come from in its tooltip. Any
* other tour says what kind it is instead.
*/
export function TourGrade({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
if (card.activity !== 'hike' || !card.grades) {
const icon = createElement(tourDayRowIcon(card.activity), { size: 10, strokeWidth: 2.2, 'aria-hidden': true });
// A bike tour or a run says what it was planned for: "Gravel", "Trail running".
const words = card.routingProfile ? PROFILE_WORDS[card.routingProfile] : TOUR_TYPE_WORDS[card.activity];
return <SoftPill icon={icon}>{t(words.labelKey)}</SoftPill>;
return <SoftPill icon={icon}>{t(TOUR_TYPE_WORDS[card.activity].labelKey)}</SoftPill>;
}
const { highest, unknownShare } = card.grades;
const uncertain = unknownShare >= 0.5;
@@ -140,7 +138,7 @@ export function TourRouteSketch({
export function TourRouteFields({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
const format = useCardFormat();
const fields: Array<{ key: string; label: string; value: string }> = factKeysOf(card).flatMap((key) => {
const fields = factKeysOf(card).flatMap((key) => {
const value = factValue(card, key);
if (value === null) return [];
// A field's label is short; how the time was reckoned is the figure badges' tooltip.
@@ -150,10 +148,6 @@ export function TourRouteFields({ card }: { card: TourRouteCard }) {
else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
return [{ key, label, value: text }];
});
// A run's time is its distance at the runner's pace: the pace stands beside it.
if (card.paceSecondsPerKm != null) {
fields.push({ key: 'pace', label: t('tours.run.pace'), value: format.pace(card.paceSecondsPerKm) });
}
return <CardFields fields={fields} />;
}
@@ -179,9 +173,9 @@ export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; o
title={name}
meta={card.label ?? undefined}
>
{((card.activity === 'hike' && card.grades) || card.routingProfile || card.schedule) && (
{((card.activity === 'hike' && card.grades) || card.schedule) && (
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
{((card.activity === 'hike' && card.grades) || card.routingProfile) && <TourGrade card={card} />}
{card.activity === 'hike' && card.grades && <TourGrade card={card} />}
<DayPill card={card} />
</div>
)}
@@ -1,5 +1,5 @@
/**
* FE-AI-TOUR-033 to FE-AI-TOUR-034, FE-AI-TOUR-063
* FE-AI-TOUR-033 to FE-AI-TOUR-034
*
* A held tour change in its row: the route drawn small with its figures and
* grade and a small profile, the kind of tour as the row's tile; a tour whose
@@ -71,10 +71,4 @@ describe('TourRouteInRow', () => {
expect(delta).toHaveTextContent('+1.6 km');
expect(delta).toHaveTextContent('+20 min');
});
it('FE-AI-TOUR-063: a held bike tour or run names its profile in its row, never a hike grade', () => {
renderRow({ ...PENDING, activity: 'bike', routingProfile: 'road', grades: null, limit: null });
expect(screen.getByText('Road')).toBeInTheDocument();
expect(screen.queryByTestId('tour-grade')).toBeNull();
});
});
@@ -53,7 +53,7 @@ export default function TourRouteInRow({ card, item }: { card: TourRouteCard; it
<span className="min-w-0 flex-1">
<WayFigures way={legVariantOf(card, 'current')} unit={unit} />
</span>
{((grades && grades.highest !== null) || card.routingProfile) && <TourGrade card={card} />}
{grades && grades.highest !== null && <TourGrade card={card} />}
</span>
{previous && (
<VariantDelta
@@ -18,7 +18,6 @@ import CardHeader from '../parts/CardHeader';
import { CardSection } from '../parts/CardSection';
import WayFigures from './TourFigureLine';
import { TourRouteBody } from './TourRouteCard';
import { TourGrade } from './TourRouteHead';
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords';
import { useLineHover, useTourRouteCard } from './useTourRouteCard';
@@ -115,8 +114,6 @@ function WayOption({
{grades && grades.highest !== null && (
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />
)}
{/* Ways of a ride or a run may differ by profile: each says its own. */}
{entry.card.routingProfile && <TourGrade card={entry.card} />}
</span>
<WayFigures way={way} unit={unit} />
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />}
@@ -99,7 +99,6 @@ export const TOUR_LOOK = {
tools: {},
steps: {
propose_city_walk: { stepKey: 'assistant.step.planningWalk' },
propose_tour: { stepKey: 'assistant.step.planningRoute' },
plan_paddle_route: { stepKey: 'assistant.step.planningRoute' },
plan_ski_route: { stepKey: 'assistant.step.planningRoute' },
get_tour_plan: { stepKey: 'assistant.step.readingTour' },
@@ -29,7 +29,6 @@ import {
sacAt,
sacPaint,
steepStretches,
takeWaitsKey,
wayGroups,
} from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel';
@@ -137,33 +136,15 @@ describe('tourRouteModel', () => {
});
});
it('FE-AI-TOUR-009: "Plan this" takes a planned way of every type, a city walk, a whole paddle or ski plan', () => {
it('FE-AI-TOUR-009: "Plan this" takes a planned hike or a city walk, nothing else', () => {
expect(canTake(HIKE)).toBe(true);
expect(canTake({ ...HIKE, routed: false })).toBe(false);
expect(canTake({ ...HIKE, activity: 'walk', routed: false })).toBe(true);
expect(canTake({ ...HIKE, activity: 'bike', routingProfile: 'gravel' })).toBe(true);
expect(canTake({ ...HIKE, activity: 'run', routingProfile: 'trail', routed: false })).toBe(false);
expect(canTake(SAVED)).toBe(false);
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(true);
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan' })).toBe(true);
// A plan with a leg that could not be planned is no tour yet; a hike is never a plan.
const failed = [{ kind: 'legs_failed' as const, at: null, count: 1 }];
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan', warnings: failed })).toBe(false);
expect(canTake({ ...HIKE, source: 'plan' })).toBe(false);
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(false);
expect(canTake({ ...HIKE, source: 'pending' })).toBe(false);
});
it("FE-AI-TOUR-060: a run waits for the runner's pace; a bike tour, a hike and a run with a pace wait for nothing", () => {
expect(takeWaitsKey({ ...HIKE, activity: 'run', routingProfile: 'running' })).toBe('assistant.card.tour.needsPace');
expect(takeWaitsKey({ ...HIKE, activity: 'run', paceSecondsPerKm: 330 })).toBeNull();
expect(takeWaitsKey({ ...HIKE, activity: 'bike' })).toBeNull();
expect(takeWaitsKey(HIKE)).toBeNull();
// A ride's router time is its riding time; a run's time is at its pace.
expect(timeLabelKey({ activity: 'bike', minutesBasis: 'router' })).toBe('tours.bike.timeEstimated');
expect(timeLabelKey({ activity: 'run', minutesBasis: 'pace' })).toBe('tours.run.timeEstimated');
expect(timeLabelKey({ activity: 'hike', minutesBasis: 'router' })).toBe('tours.scope.routed');
});
it('FE-AI-TOUR-010: proposals of one answer form ways of three at most; anything else stands alone', () => {
const entry = (toolCallId: string, card: TourRouteCard) => ({ toolCallId, card });
const routes = routeEntries([entry('a', HIKE), { toolCallId: 'list', card: TOUR_LIST }, entry('b', GENTLE)]);
@@ -226,29 +226,14 @@ export function routePoints(card: TourRouteCard): [number, number][] {
}
/**
* Whether "Plan this" can take the proposal: a city walk, a hike, a bike
* tour or a run whose way was planned, or a paddle or ski plan whose every
* leg was planned. A saved tour and a held change cannot.
* Whether "Plan this" can take the proposal: a hike whose way was planned,
* or a city walk. A paddle or ski plan, a saved tour and a held change cannot.
*/
export function canTake(card: TourRouteCard): boolean {
if (card.source === 'plan') {
const whole = card.warnings.every((warning) => warning.kind !== 'legs_failed');
return (card.activity === 'kayak' || card.activity === 'ski') && card.routed && whole;
}
if (card.source !== 'proposal') return false;
return card.activity === 'walk' || (LAND_PLANS.has(card.activity) && card.routed);
return card.activity === 'walk' || (card.activity === 'hike' && card.routed);
}
/** The proposals on land "Plan this" takes once their way is planned. */
const LAND_PLANS: ReadonlySet<TourRouteCard['activity']> = new Set(['hike', 'bike', 'run']);
/** Why "Plan this" waits on a card it can take: a run is saved with the runner's pace only. Null when nothing is missing. */
export function takeWaitsKey(card: TourRouteCard): string | null {
return card.activity === 'run' && card.paceSecondsPerKm == null ? NEEDS_PACE.labelKey : null;
}
const NEEDS_PACE = { labelKey: 'assistant.card.tour.needsPace' } as const;
/** A share as words take it, "40 %". */
export function percentText(share: number): string {
return `${Math.round(share * 100)} %`;
@@ -92,25 +92,10 @@ const TIME_WORDS = {
planned: { labelKey: 'tours.scope.planned' },
stations: { labelKey: 'tours.scope.plannedWithStays' },
piste: { labelKey: 'tours.ski.time' },
pace: { labelKey: 'tours.run.timeEstimated' },
} as const satisfies Record<NonNullable<TourRouteCard['minutesBasis']>, { labelKey: string }>;
/** What each bike and run profile is called, as the Tours tab names it. */
export const PROFILE_WORDS = {
touring: { labelKey: 'tours.bike.profile.touring' },
road: { labelKey: 'tours.bike.profile.road' },
gravel: { labelKey: 'tours.bike.profile.gravel' },
mtb: { labelKey: 'tours.bike.profile.mtb' },
running: { labelKey: 'tours.run.profile.running' },
trail: { labelKey: 'tours.run.profile.trail' },
} as const satisfies Record<NonNullable<TourRouteCard['routingProfile']>, { labelKey: string }>;
/**
* The name of a route's time: by how it was reckoned (a bike tour's router
* time is its riding time), else a paddle's own, else a walking time.
*/
/** The name of a route's time: by how it was reckoned, else a paddle's own, else a walking time. */
export function timeLabelKey(card: Pick<TourRouteCard, 'minutesBasis' | 'activity'>): string {
if (card.minutesBasis === 'router' && card.activity === 'bike') return 'tours.bike.timeEstimated';
if (card.minutesBasis) return TIME_WORDS[card.minutesBasis].labelKey;
return card.activity === 'kayak' ? 'tours.paddle.timeEstimated' : 'tours.planner.walkingEstimated';
}
@@ -11,7 +11,7 @@ import { hasUndecided } from '../../assistantParts';
import { useAssistantView } from '../../assistantViewContext';
import { useChangeHover } from '../../useChangeHover';
import { tourTarget } from './tourLook';
import { canTake, routePoints, takeWaitsKey } from './tourRouteModel';
import { canTake, routePoints } from './tourRouteModel';
import { takeInThread, takeOfProposal } from './tourTakeModel';
/**
@@ -104,8 +104,7 @@ function stateAfter(outcome: HoldOutcome, error: string | null): TakeState {
}
/**
* "Plan this" on a proposed tour (a hike, a bike tour, a run, a city walk)
* or a paddle or ski plan: one tap adds the tour. The
* "Plan this" on a proposed hike or city walk: one tap adds the tour. The
* server holds the tour it creates as a change, without a model turn, and
* the tap decides it at once, since tapping "Plan this" already is the
* decision; the assistant then answers to what was added. Once the tour is
@@ -149,10 +148,9 @@ export function useTakeProposal(toolCallId: string, card: TourRouteCard) {
}, []);
const visible = canTake(card) && !!trip && can('place_edit', trip);
let reasonKey: string | null;
let reasonKey: string | null = null;
if (offline) reasonKey = 'assistant.error.offline';
else if (waiting && state !== 'taken') reasonKey = 'assistant.composer.decideFirst';
else reasonKey = takeWaitsKey(card);
const disabled = reasonKey !== null || isRunning || threadId === null || state === 'busy';
const take = useCallback(async () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import type { BookPageSetup, BookSpread } from '@trek/shared'
import { fontStack } from './bookFonts'
import { fontStack, nearestWeight } from './bookFonts'
import { folioInk } from './folioColour'
import { isNumbered } from './bookSheets'
@@ -94,7 +94,7 @@ export function PageNumbers({
textAlign: at.align,
fontFamily: fontStack(cfg.font),
fontSize: `${size}pt`,
fontWeight: 500,
fontWeight: nearestWeight(cfg.font, 500),
letterSpacing: '0.08em',
lineHeight: 1,
color: ink.color,
+2 -2
View File
@@ -3,7 +3,7 @@ import type {
BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
} from '@trek/shared'
import { photoSrc } from './bookRender'
import { fontStack } from './bookFonts'
import { fontStack, nearestWeight } from './bookFonts'
import { iconComponent } from './iconLibrary'
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
import { TravelElementView } from './TravelElements'
@@ -417,7 +417,7 @@ export function ElementView({
// the conversion exactly.
fontSize: `${el.size}pt`,
fontFamily: fontStack(el.font),
fontWeight: el.weight,
fontWeight: nearestWeight(el.font, el.weight),
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
@@ -5,7 +5,7 @@ import { SpreadFold, SpreadView } from './SpreadView'
import { PeerCursors } from './PeerCursors'
import type { PeerCursor } from './useBookPresence'
import { bookElementId as elementId } from '@trek/shared'
import { fontStack } from './bookFonts'
import { fontStack, nearestWeight } from './bookFonts'
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
import { useStudioStore } from '../../store/studioStore'
import { useTranslation } from '../../i18n'
@@ -325,7 +325,7 @@ export function StudioCanvas({
// The document is in pt; the overlay is on screen at the same zoom.
fontSize: `${el.size * (96 / 72) * zoom}px`,
fontFamily: fontStack(el.font),
fontWeight: el.weight,
fontWeight: nearestWeight(el.font, el.weight),
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
@@ -0,0 +1,112 @@
import { blankBook, bookTextElementSchema, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, within } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore';
import { BOOK_FONT_ORDER } from './bookFonts';
import { matchFonts, pickFont } from './fontPicker';
import { StudioFontPicker } from './StudioFontPicker';
/**
* The typeface picker (FE-STUDIO-FONTPICK): the current family set in itself,
* a list grouped by kind with every family in its own face, the book's own
* families first, a search, and a weight that lands on one the family has.
*/
const t = (k: string, params?: Record<string, string | number>) =>
params ? `${k} ${Object.values(params).join(' ')}` : k;
const groupName = (c: string) => `journey.studio.fonts.cat.${c}`;
const frame = { x: 10, y: 10, w: 80, h: 20 };
function bookIn(...fonts: string[]): BookDocument {
const doc = blankBook({ title: 'Porto', newId: (p) => `${p}-x` });
const elements = fonts.map(
(font, i) => bookTextElementSchema.parse({ id: `t${i}`, kind: 'text', frame, text: 'Porto', font }) as BookElement
);
const [cover, ...rest] = doc.spreads as [BookSpread, ...BookSpread[]];
return {
...doc,
page: { ...doc.page, pageNumbers: { ...doc.page.pageNumbers, show: false } },
spreads: [{ ...cover, elements }, ...rest],
};
}
const open = () => fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.fonts\.change/ }));
afterEach(() => {
useStudioStore.setState({ doc: null });
});
describe('choosing a family', () => {
it('FE-STUDIO-FONTPICK-001: moves the weight onto one the new family has', () => {
expect(pickFont('anton', 700)).toEqual({ font: 'anton', weight: 400 });
expect(pickFont('sans', 700)).toEqual({ font: 'sans', weight: 700 });
expect(pickFont('oswald', 900)).toEqual({ font: 'oswald', weight: 700 });
});
it('FE-STUDIO-FONTPICK-002: finds families by name, id and group, in picker order', () => {
expect(matchFonts('', groupName)).toEqual(BOOK_FONT_ORDER);
expect(matchFonts(' GARA ', groupName)).toEqual(['garamond', 'cormorant']);
expect(matchFonts('mono', groupName)).toEqual(['mono']);
expect(matchFonts('cat.script', groupName)).toContain('pacifico');
expect(matchFonts('nothing like it', groupName)).toEqual([]);
});
});
describe('the picker', () => {
it('FE-STUDIO-FONTPICK-003: shows the current family set in itself and opens a grouped list with the book families first', () => {
act(() => useStudioStore.setState({ doc: bookIn('caveat', 'serif') }));
render(<StudioFontPicker font="serif" weight={400} onPick={vi.fn()} t={t} />);
const current = screen.getByRole('button', { name: 'journey.studio.fonts.change: Lora' });
expect(current.getAttribute('aria-expanded')).toBe('false');
expect(within(current).getByText('Lora').getAttribute('style')).toContain('Lora');
open();
expect(current.getAttribute('aria-expanded')).toBe('true');
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'journey.studio.fonts.search' }));
const groups = screen.getAllByRole('group');
expect(groups[0]?.getAttribute('aria-label')).toBe('journey.studio.fonts.used');
const used = within(groups[0] as HTMLElement).getAllByRole('option');
expect(used.map((o) => o.querySelector('.st-fontpick-face')?.textContent)).toEqual(['Lora', 'Caveat']);
expect(used[1]?.textContent).toContain('journey.studio.fonts.range 400 700');
// Every family, once per group it is in, each set in its own face.
const rows = screen.getAllByRole('option');
expect(rows).toHaveLength(BOOK_FONT_ORDER.length + 2);
const anton = rows.find((r) => r.textContent?.startsWith('Anton'));
expect(anton?.querySelector('span')?.getAttribute('style')).toContain('Anton');
expect(anton?.textContent).toContain('400');
expect(rows.filter((r) => r.getAttribute('aria-selected') === 'true')).toHaveLength(2);
});
it('FE-STUDIO-FONTPICK-004: picks a family at a weight it has and closes, back on the button', () => {
const onPick = vi.fn();
render(<StudioFontPicker font="sans" weight={700} onPick={onPick} t={t} />);
open();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'anton' } });
expect(screen.queryByRole('group', { name: 'journey.studio.fonts.used' })).toBeNull();
fireEvent.click(screen.getByText('Anton'));
expect(onPick).toHaveBeenCalledWith({ font: 'anton', weight: 400 });
expect(screen.queryByRole('listbox')).toBeNull();
expect(document.activeElement?.getAttribute('aria-expanded')).toBe('false');
});
it('FE-STUDIO-FONTPICK-005: says when nothing matches, clears the search, and closes on Escape', () => {
render(<StudioFontPicker font="sans" weight={400} onPick={vi.fn()} t={t} />);
open();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzz' } });
expect(screen.getByText('journey.studio.fonts.none')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'common.clear' }));
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('');
expect(screen.queryByText('journey.studio.fonts.none')).toBeNull();
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Escape' });
expect(screen.queryByRole('listbox')).toBeNull();
// A second click on the open button closes it too.
open();
open();
expect(screen.queryByRole('listbox')).toBeNull();
});
it('FE-STUDIO-FONTPICK-006: reads a family this build does not know as the default one', () => {
render(<StudioFontPicker font="gone" weight={400} onPick={vi.fn()} t={t} />);
expect(screen.getByRole('button', { name: 'journey.studio.fonts.change: Poppins' })).toBeTruthy();
});
});
@@ -0,0 +1,198 @@
import { BOOK_FONT_CATEGORIES, bookFontsUsed, type BookFontCategory } from '@trek/shared';
import { Check, ChevronDown, Search, X } from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-fonts.css';
import { BOOK_FONTS, nearestWeight, type BookFontId } from './bookFonts';
import { matchFonts, pickFont, weightSpan, type FontPick, type FontT } from './fontPicker';
/**
* The typeface picker, shared by the text inspector and the travel inspector.
*
* Thirty four families do not fit as a wall of buttons the way seven did, and
* a plain list of names says nothing about how a face looks, which is the only
* thing anyone is choosing between. So the current family sits on one line,
* set in itself, and opens a list grouped the way people look for type
* (a sans, a serif, something for a poster, a script to lay over a photo)
* where every family is again set in its own face. The families the book is
* already set in come first, because the second text of a book is usually set
* like the first.
*
* The list opens inside the inspector rather than floating over the page: the
* inspector scrolls, and a popover pinned to a control that scrolls away is a
* popover pointing at nothing.
*/
function FontRow({
id,
on,
weight,
onPick,
t,
}: {
id: BookFontId;
on: boolean;
weight: number;
onPick: () => void;
t: FontT;
}) {
const face = BOOK_FONTS[id];
return (
<button
type="button"
role="option"
aria-selected={on}
className={`st-fontpick-row ${on ? 'is-on' : ''}`}
onClick={onPick}
title={face.name}
>
<span className="st-fontpick-face" style={{ fontFamily: face.stack, fontWeight: nearestWeight(id, weight) }}>
{face.name}
</span>
<span className="st-fontpick-span">{weightSpan(id, t)}</span>
<span className="st-fontpick-tick">{on && <Check size={13} />}</span>
</button>
);
}
function FontGroup({
label,
ids,
font,
weight,
onPick,
t,
}: {
label: string;
ids: BookFontId[];
font: string;
weight: number;
onPick: (id: BookFontId) => void;
t: FontT;
}) {
if (!ids.length) return null;
return (
<div className="st-fontpick-group" role="group" aria-label={label}>
<div className="st-fontpick-label">{label}</div>
{ids.map((id) => (
<FontRow key={id} id={id} on={font === id} weight={weight} onPick={() => onPick(id)} t={t} />
))}
</div>
);
}
/** The families the open book is set in, read from the document the Studio holds. */
function useFontsInBook(): BookFontId[] {
const doc = useStudioStore((s) => s.doc);
return useMemo(() => (doc ? bookFontsUsed(doc) : []), [doc]);
}
export function StudioFontPicker({
font,
weight,
onPick,
t,
}: {
font: string;
weight: number;
onPick: (patch: FontPick) => void;
t: FontT;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const trigger = useRef<HTMLButtonElement>(null);
const search = useRef<HTMLInputElement>(null);
const listId = useId();
const inBook = useFontsInBook();
const current = BOOK_FONTS[font as BookFontId] ?? BOOK_FONTS.sans;
const groupName = (c: BookFontCategory) => t(`journey.studio.fonts.cat.${c}`);
const found = matchFonts(query, groupName);
// Opened on purpose, so typing goes straight to the search.
useEffect(() => {
if (open) search.current?.focus();
}, [open]);
const close = () => {
setOpen(false);
setQuery('');
trigger.current?.focus();
};
const choose = (id: BookFontId) => {
onPick(pickFont(id, weight));
close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.stopPropagation();
close();
};
return (
<div className={`st-fontpick ${open ? 'is-open' : ''}`} onKeyDown={onKey}>
<button
ref={trigger}
type="button"
className="st-fontpick-current"
aria-expanded={open}
aria-controls={listId}
aria-label={`${t('journey.studio.fonts.change')}: ${current.name}`}
onClick={() => (open ? close() : setOpen(true))}
>
<span
className="st-fontpick-face"
style={{ fontFamily: current.stack, fontWeight: nearestWeight(current.id, weight) }}
>
{current.name}
</span>
<span className="st-fontpick-span">{groupName(current.category)}</span>
<ChevronDown size={14} className="st-fontpick-caret" />
</button>
{open && (
<div className="st-fontpick-panel">
<div className="st-search st-fontpick-search">
<Search size={14} />
<input
ref={search}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('journey.studio.fonts.search')}
aria-label={t('journey.studio.fonts.search')}
aria-controls={listId}
spellCheck={false}
/>
{query && (
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
</div>
<div id={listId} className="st-fontpick-list" role="listbox" aria-label={t('journey.studio.fonts.list')}>
{!query && (
<FontGroup
label={t('journey.studio.fonts.used')}
ids={inBook}
font={font}
weight={weight}
onPick={choose}
t={t}
/>
)}
{BOOK_FONT_CATEGORIES.map((category) => (
<FontGroup
key={category}
label={groupName(category)}
ids={found.filter((id) => BOOK_FONTS[id].category === category)}
font={font}
weight={weight}
onPick={choose}
t={t}
/>
))}
{!found.length && <p className="st-hint st-fontpick-none">{t('journey.studio.fonts.none')}</p>}
</div>
</div>
)}
</div>
);
}
@@ -13,7 +13,9 @@ import { Swatches } from './StudioSwatches'
import { formatBookCoords } from '@trek/shared'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
import { FontButtons, WeightLine } from './StudioTypeControls'
import { StudioFontPicker } from './StudioFontPicker'
import { WeightLine } from './StudioTypeControls'
import { italicTitle } from './fontPicker'
import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip'
@@ -323,7 +325,7 @@ export function StudioInspector({
nothing about a typeface — and grouped, because "which serif"
is a different question from "serif or sans".
*/}
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch)} />
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch)} t={t} />
<div style={{ marginTop: 10 }}>
<WeightLine font={el.font} weight={el.weight} onPick={weight => set({ weight })} t={t} />
<Line label={t('journey.studio.align')}>
@@ -344,7 +346,7 @@ export function StudioInspector({
<button type="button"
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
onClick={() => set({ italic: !el.italic })}
title={t('journey.studio.italic')}
title={italicTitle(el.font, el.weight, t)}
aria-label={t('journey.studio.italic')}
aria-pressed={el.italic}
>
@@ -26,6 +26,7 @@ import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
import '../../styles/dashboard.css'
import '../../styles/studio.css'
import './bookFontFaces'
import { useWarmBookFaces } from './bookFontWarm'
import HelpButton from '../Help/HelpButton'
/**
@@ -45,6 +46,8 @@ export default function StudioShell() {
const [bookView, setBookView] = useState(true)
const [exporting, setExporting] = useState(false)
const assistant = useStudioAssistant({ journeyId: s.journeyId, flush: s.flushBook })
// The book's faces into the offline cache as it opens (bookFontWarm.ts).
useWarmBookFaces()
if (s.isMobile) {
return createPortal(
@@ -5,7 +5,8 @@ import type {
} from '@trek/shared'
import { BOOK_METRICS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch } from '@trek/shared'
import { Swatches } from './StudioSwatches'
import { FontButtons, WeightLine } from './StudioTypeControls'
import { StudioFontPicker } from './StudioFontPicker'
import { WeightLine } from './StudioTypeControls'
import { sourceIdOf, useMapSources } from './mapSources'
import { printDpi, tileView } from './mapTiles'
import { useCartoApiKey } from '../../hooks/useTileUrl'
@@ -82,7 +83,7 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
* wrong typeface and no way to fix it.
*/}
<Section label={t('journey.studio.typography')} defaultOpen={false}>
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} />
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} t={t} />
<div style={{ marginTop: 10 }}>
<WeightLine
font={el.font}
@@ -1,50 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { BOOK_FONT_ORDER } from './bookFonts';
import { FontButtons, WeightLine } from './StudioTypeControls';
import { italicTitle } from './fontPicker';
import { WeightLine } from './StudioTypeControls';
const t = (k: string) => k;
describe('FontButtons', () => {
it('offers every bundled family in the picker order and marks the current one', () => {
render(<FontButtons font="serif" weight={400} onPick={vi.fn()} />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(BOOK_FONT_ORDER.length);
expect(screen.getByRole('button', { name: 'Lora' }).className).toContain('is-on');
expect(screen.getByRole('button', { name: 'Poppins' }).className).not.toContain('is-on');
});
it('keeps the weight when the new family ships it', () => {
const onPick = vi.fn();
render(<FontButtons font="sans" weight={700} onPick={onPick} />);
fireEvent.click(screen.getByRole('button', { name: 'Inter' }));
expect(onPick).toHaveBeenCalledWith({ font: 'inter', weight: 700 });
});
it('moves the weight to one the new family really has', () => {
const onPick = vi.fn();
render(<FontButtons font="sans" weight={700} onPick={onPick} />);
fireEvent.click(screen.getByRole('button', { name: 'Bebas Neue' }));
expect(onPick).toHaveBeenCalledWith({ font: 'bebas', weight: 400 });
});
});
describe('WeightLine', () => {
it('greys the weights the family does not ship and says why', () => {
render(<WeightLine font="bebas" weight={400} onPick={vi.fn()} t={t} />);
it('steps through only the weights the family has and says the drawn one by name and number', () => {
render(<WeightLine font="oswald" weight={700} onPick={vi.fn()} t={t} />);
expect(screen.getByText('journey.studio.weight')).toBeTruthy();
const bold = screen.getByRole('radio', { name: '700' }) as HTMLButtonElement;
expect(bold.disabled).toBe(true);
expect(bold.title).toBe('journey.studio.weightMissing');
const regular = screen.getByRole('radio', { name: '400' }) as HTMLButtonElement;
expect(regular.disabled).toBe(false);
expect(regular.getAttribute('aria-checked')).toBe('true');
const slider = screen.getByRole('slider') as HTMLInputElement;
// Oswald ships 200 to 700: six stops, the last one drawn.
expect([slider.min, slider.max, slider.value]).toEqual(['0', '5', '5']);
expect(slider.getAttribute('aria-valuetext')).toBe('journey.studio.weight.700 700');
expect(screen.getByText('journey.studio.weight.700 700')).toBeTruthy();
});
it('hands the picked weight back', () => {
it('hands back the weight at the stop picked', () => {
const onPick = vi.fn();
render(<WeightLine font="sans" weight={400} onPick={onPick} t={t} />);
fireEvent.click(screen.getByRole('radio', { name: '600' }));
expect(onPick).toHaveBeenCalledWith(600);
fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } });
expect(onPick).toHaveBeenCalledWith(100);
fireEvent.change(screen.getByRole('slider'), { target: { value: '8' } });
expect(onPick).toHaveBeenLastCalledWith(900);
});
it('shows the one weight of a family that has one, with nothing to drag, and the weight it really draws', () => {
render(<WeightLine font="bebas" weight={700} onPick={vi.fn()} t={t} />);
expect(screen.queryByRole('slider')).toBeNull();
expect(screen.getByText('journey.studio.weightOnly')).toBeTruthy();
expect(screen.getByText('journey.studio.weight.400 400')).toBeTruthy();
});
it('reads a family this build does not know as the default one', () => {
render(<WeightLine font="gone" weight={500} onPick={vi.fn()} t={t} />);
expect((screen.getByRole('slider') as HTMLInputElement).value).toBe('4');
});
});
describe('italicTitle', () => {
it('says when the browser slants a family that has no italic of its own', () => {
expect(italicTitle('garamond', 400, t)).toBe('journey.studio.italic');
expect(italicTitle('sans', 400, t)).toBe('journey.studio.italicSlanted');
expect(italicTitle('playfair', 300, t)).toBe('journey.studio.italic');
});
});
@@ -1,58 +1,27 @@
import type { BookTextElement } from '@trek/shared';
import type { BookFontId } from './bookFonts';
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts';
import { Choice, Line } from './StudioControls';
import type { BookWeight } from '@trek/shared';
import { BOOK_FONTS, nearestWeight, type BookFontId } from './bookFonts';
import { Line } from './StudioControls';
/**
* The typeface buttons and the weight line, shared by the text inspector and
* the travel inspector so both set type the same way.
* The weight line, shared by the text inspector and the travel inspector so
* both set type the same way. The typeface itself is picked in
* StudioFontPicker.tsx, the italic's title comes from fontPicker.ts.
*/
/** The weight set the book schema allows, taken from it so there is one list. */
type BookWeight = BookTextElement['weight'];
type T = (key: string) => string;
const WEIGHTS = [400, 500, 600, 700] as const;
/** The weights a family ships, lightest first; Poppins' for a family this build does not know. */
const weightsOf = (font: string) => (BOOK_FONTS[font as BookFontId] ?? BOOK_FONTS.sans).weights;
/** One button per bundled family, each set in its own face. */
export function FontButtons({
font,
weight,
onPick,
}: {
font: string;
weight: BookWeight;
onPick: (patch: { font: BookFontId; weight: BookWeight }) => void;
}) {
return (
<div className="st-fonts">
{BOOK_FONT_ORDER.map((id) => {
const face = BOOK_FONTS[id];
return (
<button
type="button"
key={id}
className={`st-font ${font === id ? 'is-on' : ''}`}
style={{ fontFamily: face.stack }}
onClick={() =>
onPick({
font: id,
// A family that does not ship this weight would render a
// synthesised bold, a smeared regular in print, so the
// weight moves to the nearest one it really has.
weight: nearestWeight(id, weight) as BookWeight,
})
}
title={face.name}
>
{face.name}
</button>
);
})}
</div>
);
}
/** The four weights, with the ones the family does not ship greyed out. */
/**
* A slider over the weights the family really has, and only those.
*
* Nine chips from 100 to 900 with most of them greyed out read as a broken
* control for a family with two weights, and as a wall for one with nine.
* The slider steps through what exists, says the weight by its name and its
* number, and a family with a single weight shows that weight and nothing to
* drag.
*/
export function WeightLine({
font,
weight,
@@ -60,22 +29,36 @@ export function WeightLine({
t,
}: {
font: string;
weight: BookWeight;
weight: number;
onPick: (weight: BookWeight) => void;
t: (k: string) => string;
t: T;
}) {
const weights = weightsOf(font);
const drawn = nearestWeight(font, weight);
const at = Math.max(0, weights.indexOf(drawn as BookWeight));
const word = `${t(`journey.studio.weight.${drawn}`)} ${drawn}`;
return (
<Line label={t('journey.studio.weight')}>
<Choice
value={weight}
options={WEIGHTS.map((w) => ({
value: w,
label: String(w),
disabled: !hasWeight(font, w),
title: hasWeight(font, w) ? undefined : t('journey.studio.weightMissing'),
}))}
onPick={onPick}
/>
<div className="st-field st-weight">
{weights.length > 1 ? (
<input
type="range"
min={0}
max={weights.length - 1}
step={1}
value={at}
aria-label={t('journey.studio.weight')}
aria-valuetext={word}
onChange={(e) => {
const next = weights[Number(e.target.value)];
if (next !== undefined) onPick(next);
}}
/>
) : (
<span className="st-weight-only">{t('journey.studio.weightOnly')}</span>
)}
<span className="st-weight-value">{word}</span>
</div>
</Line>
);
}
@@ -7,7 +7,7 @@ import {
Camera, CalendarDays, Footprints, Globe, MapPin, Navigation, Route,
type LucideIcon,
} from 'lucide-react'
import { fontStack } from './bookFonts'
import { fontStack, lighterWeight, nearestWeight } from './bookFonts'
import { haversineKm } from '../../utils/geo'
import { brightness } from './folioColour'
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
@@ -140,8 +140,8 @@ export function TravelElementView({ el, frameStyle, big = false }: {
* `textScale` moves their size. Hard-coded 700s meant the only way to lighten a
* stats block was to not use one.
*/
const heavy = (el: { weight: number }) => el.weight
const light = (el: { weight: number }) => Math.max(400, el.weight - 100)
const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight)
const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight)
const MAP_PALETTES = {
minimal: { land: '#e9e5dc', border: '#d6d0c4', onDark: false },
+184 -14
View File
@@ -1,43 +1,213 @@
/**
* The @font-face rules behind the book's typefaces.
*
* bookFonts.ts names the seven families; this is where the faces they need
* arrive. Poppins comes with the app chrome in main.tsx and is not repeated
* here, and neither are MuseoModerno's 400 and 700: importing a face twice only
* duplicates every rule. Its 500 and 600 are the book's alone, which is why
* they are down here rather than up there.
* The catalogue in @trek/shared (book-fonts.data.ts) names the families, the
* weights each ships and the weights it has a real italic for; this is where
* those faces arrive, one import per face. Poppins 300 to 700 and
* MuseoModerno 400, 700 and 800 come with the app chrome in main.tsx and are
* not repeated here: importing a face twice only duplicates every rule.
*
* Pulled in by StudioShell rather than by main.tsx: nothing outside the editor
* is set in Lora or Bebas Neue, so the faces travel with the Studio chunk and
* cost the app's first paint nothing. Once that chunk is loaded they are
* ordinary stylesheets on the document, which is what lets printSheets copy
* them into the print frame with everything else.
* is set in Lora or Anton, so the rules travel with the Studio chunk and cost
* the app's first paint nothing. Once that chunk is loaded they are ordinary
* stylesheets on the document, which is what lets printSheets copy them into
* the print frame with everything else.
*
* Upright only, as everywhere else in the app: the editor's italic toggle has
* always been the browser's synthetic slant, and a second file per weight is a
* lot of download to change that.
* A rule is not a download. The browser fetches a face's file only when text
* on the page is set in it, and only the subsets (latin, latin-ext, cyrillic
* and so on) whose characters that text uses, so a book set in two families
* loads two families however long this list grows. The print frame loads the
* same way, and the assistant's page snapshot embeds only the faces a page
* draws (snapshotFonts.ts).
*
* Real italics for the serifs, where the browser's slant reads as a mistake;
* the sans faces keep the synthetic oblique they always had, which saves a
* second file per weight nobody would tell apart.
*
* Big Shoulders Display is imported without `.css`: its package maps `./*` to
* `./*.css` and has no `./*.css` entry of its own, so `100.css` would resolve
* to `100.css.css` and fail the build.
*/
import '@fontsource/poppins/100.css'
import '@fontsource/poppins/200.css'
import '@fontsource/poppins/800.css'
import '@fontsource/poppins/900.css'
import '@fontsource/inter/100.css'
import '@fontsource/inter/200.css'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/600.css'
import '@fontsource/inter/700.css'
import '@fontsource/inter/800.css'
import '@fontsource/inter/900.css'
import '@fontsource/montserrat/100.css'
import '@fontsource/montserrat/200.css'
import '@fontsource/montserrat/300.css'
import '@fontsource/montserrat/400.css'
import '@fontsource/montserrat/500.css'
import '@fontsource/montserrat/600.css'
import '@fontsource/montserrat/700.css'
import '@fontsource/montserrat/800.css'
import '@fontsource/montserrat/900.css'
import '@fontsource/lora/400.css'
import '@fontsource/lora/500.css'
import '@fontsource/lora/600.css'
import '@fontsource/lora/700.css'
import '@fontsource/lora/400-italic.css'
import '@fontsource/lora/500-italic.css'
import '@fontsource/lora/600-italic.css'
import '@fontsource/lora/700-italic.css'
import '@fontsource/eb-garamond/400.css'
import '@fontsource/eb-garamond/500.css'
import '@fontsource/eb-garamond/600.css'
import '@fontsource/eb-garamond/700.css'
import '@fontsource/eb-garamond/800.css'
import '@fontsource/eb-garamond/400-italic.css'
import '@fontsource/eb-garamond/500-italic.css'
import '@fontsource/eb-garamond/600-italic.css'
import '@fontsource/eb-garamond/700-italic.css'
import '@fontsource/eb-garamond/800-italic.css'
import '@fontsource/playfair-display/400.css'
import '@fontsource/playfair-display/500.css'
import '@fontsource/playfair-display/600.css'
import '@fontsource/playfair-display/700.css'
import '@fontsource/playfair-display/800.css'
import '@fontsource/playfair-display/900.css'
import '@fontsource/playfair-display/400-italic.css'
import '@fontsource/playfair-display/500-italic.css'
import '@fontsource/playfair-display/600-italic.css'
import '@fontsource/playfair-display/700-italic.css'
import '@fontsource/playfair-display/800-italic.css'
import '@fontsource/playfair-display/900-italic.css'
import '@fontsource/museomoderno/500.css'
import '@fontsource/museomoderno/600.css'
import '@fontsource/fraunces/100.css'
import '@fontsource/fraunces/200.css'
import '@fontsource/fraunces/300.css'
import '@fontsource/fraunces/400.css'
import '@fontsource/fraunces/500.css'
import '@fontsource/fraunces/600.css'
import '@fontsource/fraunces/700.css'
import '@fontsource/fraunces/800.css'
import '@fontsource/fraunces/900.css'
import '@fontsource/fraunces/100-italic.css'
import '@fontsource/fraunces/200-italic.css'
import '@fontsource/fraunces/300-italic.css'
import '@fontsource/fraunces/400-italic.css'
import '@fontsource/fraunces/500-italic.css'
import '@fontsource/fraunces/600-italic.css'
import '@fontsource/fraunces/700-italic.css'
import '@fontsource/fraunces/800-italic.css'
import '@fontsource/fraunces/900-italic.css'
import '@fontsource/cormorant-garamond/300.css'
import '@fontsource/cormorant-garamond/400.css'
import '@fontsource/cormorant-garamond/500.css'
import '@fontsource/cormorant-garamond/600.css'
import '@fontsource/cormorant-garamond/700.css'
import '@fontsource/cormorant-garamond/300-italic.css'
import '@fontsource/cormorant-garamond/400-italic.css'
import '@fontsource/cormorant-garamond/500-italic.css'
import '@fontsource/cormorant-garamond/600-italic.css'
import '@fontsource/cormorant-garamond/700-italic.css'
import '@fontsource/dm-serif-display/400.css'
import '@fontsource/dm-serif-display/400-italic.css'
import '@fontsource/bodoni-moda/400.css'
import '@fontsource/bodoni-moda/500.css'
import '@fontsource/bodoni-moda/600.css'
import '@fontsource/bodoni-moda/700.css'
import '@fontsource/bodoni-moda/800.css'
import '@fontsource/bodoni-moda/900.css'
import '@fontsource/bodoni-moda/400-italic.css'
import '@fontsource/bodoni-moda/500-italic.css'
import '@fontsource/bodoni-moda/600-italic.css'
import '@fontsource/bodoni-moda/700-italic.css'
import '@fontsource/bodoni-moda/800-italic.css'
import '@fontsource/bodoni-moda/900-italic.css'
import '@fontsource/bebas-neue/400.css'
import '@fontsource/anton/400.css'
import '@fontsource/oswald/200.css'
import '@fontsource/oswald/300.css'
import '@fontsource/oswald/400.css'
import '@fontsource/oswald/500.css'
import '@fontsource/oswald/600.css'
import '@fontsource/oswald/700.css'
import '@fontsource/league-gothic/400.css'
import '@fontsource/big-shoulders-display/100'
import '@fontsource/big-shoulders-display/200'
import '@fontsource/big-shoulders-display/300'
import '@fontsource/big-shoulders-display/400'
import '@fontsource/big-shoulders-display/500'
import '@fontsource/big-shoulders-display/600'
import '@fontsource/big-shoulders-display/700'
import '@fontsource/big-shoulders-display/800'
import '@fontsource/big-shoulders-display/900'
import '@fontsource/archivo-black/400.css'
import '@fontsource/museomoderno/100.css'
import '@fontsource/museomoderno/200.css'
import '@fontsource/museomoderno/300.css'
import '@fontsource/museomoderno/500.css'
import '@fontsource/museomoderno/600.css'
import '@fontsource/museomoderno/900.css'
import '@fontsource/shrikhand/400.css'
import '@fontsource/alfa-slab-one/400.css'
import '@fontsource/bricolage-grotesque/200.css'
import '@fontsource/bricolage-grotesque/300.css'
import '@fontsource/bricolage-grotesque/400.css'
import '@fontsource/bricolage-grotesque/500.css'
import '@fontsource/bricolage-grotesque/600.css'
import '@fontsource/bricolage-grotesque/700.css'
import '@fontsource/bricolage-grotesque/800.css'
import '@fontsource/abril-fatface/400.css'
import '@fontsource/caveat/400.css'
import '@fontsource/caveat/500.css'
import '@fontsource/caveat/600.css'
import '@fontsource/caveat/700.css'
import '@fontsource/dancing-script/400.css'
import '@fontsource/dancing-script/500.css'
import '@fontsource/dancing-script/600.css'
import '@fontsource/dancing-script/700.css'
import '@fontsource/pacifico/400.css'
import '@fontsource/great-vibes/400.css'
import '@fontsource/sacramento/400.css'
import '@fontsource/kaushan-script/400.css'
import '@fontsource/allura/400.css'
import '@fontsource/parisienne/400.css'
import '@fontsource/satisfy/400.css'
import '@fontsource/yellowtail/400.css'
import '@fontsource/homemade-apple/400.css'
import '@fontsource/permanent-marker/400.css'
import '@fontsource/dm-mono/300.css'
import '@fontsource/dm-mono/400.css'
import '@fontsource/dm-mono/500.css'
@@ -0,0 +1,79 @@
import { act, renderHook } from '@testing-library/react';
import { blankBook, bookTextElementSchema, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useStudioStore } from '../../store/studioStore';
import { bookSample, faceSpec, useWarmBookFaces, warmBookFaces } from './bookFontWarm';
/**
* The open book's faces go into the offline cache as it opens
* (FE-STUDIO-FONTWARM): each face it uses is asked for once, with the book's
* own characters, and a face that does not load costs nothing.
*/
const frame = { x: 10, y: 10, w: 80, h: 20 };
const text = (id: string, words: string, over: Record<string, unknown> = {}) =>
bookTextElementSchema.parse({ id, kind: 'text', frame, text: words, ...over }) as BookElement;
function book(elements: BookElement[]): BookDocument {
const doc = blankBook({ title: 'Kraków', newId: (p) => `${p}-x` });
const [cover, ...rest] = doc.spreads as [BookSpread, ...BookSpread[]];
return { ...doc, spreads: [{ ...cover, elements }, ...rest] };
}
afterEach(() => {
useStudioStore.setState({ doc: null });
});
describe('the faces of a book', () => {
it('FE-STUDIO-FONTWARM-001: samples each character of the book once, the title and the figures with it', () => {
const sample = bookSample(book([text('t1', 'Łódź Łódź'), text('t2', ' ')]));
expect(sample).toContain('Ł');
expect(sample).toContain('ó');
expect(sample).toContain('K');
expect(sample.startsWith('0123456789')).toBe(true);
expect(new Set(sample).size).toBe(sample.length);
expect(sample).not.toContain(' ');
});
it('FE-STUDIO-FONTWARM-002: names a face the way document.fonts reads it', () => {
expect(faceSpec({ font: 'serif', weight: 700, italic: true })).toBe('italic 700 16px "Lora"');
expect(faceSpec({ font: 'caveat', weight: 400, italic: false })).toBe('400 16px "Caveat"');
});
it('FE-STUDIO-FONTWARM-003: asks for every face once, counts the ones that loaded and never rejects', async () => {
const doc = book([
text('t1', 'Porto', { font: 'caveat', weight: 700 }),
text('t2', 'Douro', { font: 'serif', italic: true }),
]);
const load = vi.fn(async (spec: string, _text?: string) => (spec.includes('Caveat') ? [{} as FontFace] : []));
expect(await warmBookFaces(doc, { load })).toBe(1);
const specs = load.mock.calls.map(([spec]) => spec);
expect(specs).toEqual(expect.arrayContaining(['700 16px "Caveat"', 'italic 400 16px "Lora"']));
expect(load.mock.calls[0]?.[1]).toContain('P');
const broken = vi.fn(async () => {
throw new Error('offline');
});
expect(await warmBookFaces(doc, { load: broken })).toBe(0);
expect(await warmBookFaces(doc, undefined)).toBe(0);
});
it('FE-STUDIO-FONTWARM-004: warms when the set of faces changes, not on every edit of the words', async () => {
const load = vi.fn(async () => [] as FontFace[]);
Object.defineProperty(document, 'fonts', { configurable: true, value: { load } });
const doc = book([text('t1', 'Porto', { font: 'anton' })]);
act(() => useStudioStore.setState({ doc }));
renderHook(() => useWarmBookFaces());
await act(async () => {});
const first = load.mock.calls.length;
expect(first).toBeGreaterThan(0);
const retyped = book([text('t1', 'Porto, again', { font: 'anton' })]);
await act(async () => useStudioStore.setState({ doc: retyped }));
expect(load.mock.calls.length).toBe(first);
const another = book([text('t1', 'Porto', { font: 'anton' }), text('t2', 'Lisboa', { font: 'vibes' })]);
await act(async () => useStudioStore.setState({ doc: another }));
expect(load.mock.calls.length).toBeGreaterThan(first);
});
});
@@ -0,0 +1,64 @@
import { BOOK_FONT_FACES, bookFacesUsed, type BookDocument, type BookFaceUse } from '@trek/shared';
import { useEffect, useMemo } from 'react';
import { useStudioStore } from '../../store/studioStore';
/**
* The faces of the open book, loaded as soon as it opens, so it reads in its
* own type offline as well.
*
* The service worker installs the latin files of the seven families the book
* shipped with (vite.config.js); every other face is cached by its runtime
* rule the first time it is fetched. A face the browser only fetches once
* text is drawn in it would be cached only for the pages someone happened to
* look at, so a book opened online and read offline later would show its
* third chapter in the fallback. Asking for every face the book uses, with the
* characters it sets, fetches each one now, through the worker, into that
* cache: a face nobody uses is never downloaded.
*/
/** The book's own characters, for asking the right subsets of each face: its words, its title, the figures. */
export function bookSample(doc: BookDocument): string {
const seen = new Set<string>('0123456789');
const take = (s: string) => {
for (const ch of s) if (ch.trim()) seen.add(ch);
};
take(doc.title);
for (const spread of doc.spreads) {
for (const el of spread.elements) if (el.kind === 'text') take(el.text);
}
// A face's subsets are chosen by its unicode ranges, so one character of each is enough; the cap keeps it short.
return [...seen].slice(0, 512).join('');
}
/** One face as the CSS font shorthand `document.fonts.load` takes. */
export function faceSpec(use: BookFaceUse): string {
const family = BOOK_FONT_FACES[use.font].name;
return `${use.italic ? 'italic ' : ''}${use.weight} 16px "${family}"`;
}
type FontLoader = Pick<FontFaceSet, 'load'>;
/** Fetch every face the book needs; resolves with how many loaded, never rejects. */
export async function warmBookFaces(
doc: BookDocument,
fonts: FontLoader | undefined = globalThis.document?.fonts
): Promise<number> {
if (!fonts) return 0;
const sample = bookSample(doc);
const loads = await Promise.allSettled(bookFacesUsed(doc).map((use) => fonts.load(faceSpec(use), sample)));
return loads.filter((r) => r.status === 'fulfilled' && r.value.length > 0).length;
}
/**
* Warms the faces of the book the Studio holds whenever the set of faces it
* uses changes; typing into a text does not, so an edit costs nothing here.
*/
export function useWarmBookFaces(): void {
const doc = useStudioStore((s) => s.doc);
const key = useMemo(() => (doc ? bookFacesUsed(doc).map(faceSpec).sort().join('|') : ''), [doc]);
useEffect(() => {
const current = useStudioStore.getState().doc;
// A face that will not load is drawn in its fallback, which the picker already shows; nothing to tell anyone.
if (key && current) void warmBookFaces(current);
}, [key]);
}
+38 -105
View File
@@ -1,118 +1,51 @@
import {
BOOK_FONT_CATEGORIES, BOOK_FONT_FACES, BOOK_FONTS_IDS, bookFontHasItalic, bookFontHasWeight, bookLighterWeight,
nearestBookWeight,
type BookFontCategory, type BookFontFace, type BookFontFamily,
} from '@trek/shared'
/**
* The book's typefaces.
* The book's typefaces, as the Studio draws them.
*
* ── Why this file exists ──────────────────────────────────────────────────
*
* The font stack used to name Georgia for its serif. Georgia is a system font:
* it is on most desktops and on no Linux container, so the print renderer would
* quietly substitute something else and a book proofread in one typeface would
* print in another. The existing Journey PDF export has the same bug today — it
* asks for Inter while only Poppins, Geist and MuseoModerno are bundled.
* print in another.
*
* So every family here is self-hosted through @fontsource, all of them OFL, and
* So every family is self-hosted through @fontsource, OFL or Apache 2.0, and
* every stack ends in a generic rather than in another named font nobody
* shipped. What the editor draws is what the renderer has.
*
* The faces themselves are loaded next door in bookFontFaces.ts. A family named
* here and not imported there falls back to exactly the system font this file
* was written against, which is how five of the seven shipped (#2183).
*
* ── Why these seven ───────────────────────────────────────────────────────
*
* A photo book needs more than one voice and fewer than a menu. Two workhorse
* sans faces, three serifs across the range from bookish to high-contrast, and
* two display faces for a cover. Anything past that is a font picker rather
* than a design decision.
* The catalogue itself (names, stacks, weights, italics, roles) lives in
* @trek/shared (book-fonts.data.ts), because the findings, the design engine
* and the assistant read the same families. This file only gives the Studio
* its view of it; the faces are loaded next door in bookFontFaces.ts.
*/
export interface BookFont {
export type BookFontId = BookFontFamily
export interface BookFont extends BookFontFace {
id: BookFontId
/** What it is called, shown as itself in the picker. */
name: string
/** The CSS stack. Ends generic — never in a font that is not bundled. */
stack: string
/** Where it belongs, for grouping the picker. */
kind: 'sans' | 'serif' | 'display'
/**
* Weights actually loaded. Asking for 700 on a family that ships one weight
* gets a synthesised bold, which prints as a smeared version of the regular.
*/
weights: number[]
}
export type BookFontId =
| 'sans' | 'serif' | 'display'
| 'inter' | 'garamond' | 'playfair' | 'bebas'
/**
* `sans`, `serif` and `display` keep their names because documents already
* contain them — the three original slots, now pointing at bundled families
* rather than at whatever the machine happened to have.
*/
export const BOOK_FONTS: Record<BookFontId, BookFont> = {
sans: {
id: 'sans',
name: 'Poppins',
stack: '"Poppins", system-ui, sans-serif',
kind: 'sans',
weights: [400, 500, 600, 700],
},
inter: {
id: 'inter',
name: 'Inter',
stack: '"Inter", system-ui, sans-serif',
kind: 'sans',
weights: [400, 500, 600, 700],
},
serif: {
id: 'serif',
name: 'Lora',
stack: '"Lora", Georgia, serif',
kind: 'serif',
weights: [400, 500, 600, 700],
},
garamond: {
id: 'garamond',
name: 'EB Garamond',
stack: '"EB Garamond", Georgia, serif',
kind: 'serif',
weights: [400, 500, 600, 700],
},
playfair: {
id: 'playfair',
name: 'Playfair Display',
stack: '"Playfair Display", Georgia, serif',
kind: 'serif',
weights: [400, 500, 600, 700],
},
display: {
id: 'display',
name: 'MuseoModerno',
stack: '"MuseoModerno", "Poppins", system-ui, sans-serif',
kind: 'display',
weights: [400, 500, 600, 700],
},
bebas: {
id: 'bebas',
name: 'Bebas Neue',
stack: '"Bebas Neue", Impact, sans-serif',
kind: 'display',
// One weight only. Asking for bold gets a synthesised one, which is why
// the inspector greys the weights this family does not have.
weights: [400],
},
}
export const BOOK_FONTS = Object.fromEntries(
BOOK_FONTS_IDS.map(id => [id, { id, ...BOOK_FONT_FACES[id] }]),
) as unknown as Record<BookFontId, BookFont>
// Geist Sans is deliberately absent: it is TREK's own subtext face, part of the
// app's voice rather than the book's, and a book set in the interface font
// reads as a screenshot of the app.
/** The picker's order: workhorses first, display last. */
export const BOOK_FONT_ORDER: BookFontId[] = [
'sans', 'inter',
'serif', 'garamond', 'playfair',
'display', 'bebas',
]
/** The picker's order: by category (sans first, mono last), the catalogue's order within one. */
export const BOOK_FONT_ORDER: BookFontId[] = BOOK_FONT_CATEGORIES.flatMap(category =>
BOOK_FONTS_IDS.filter(id => BOOK_FONT_FACES[id].category === category))
/** The families of one picker group, in picker order. */
export function fontsIn(category: BookFontCategory): BookFontId[] {
return BOOK_FONT_ORDER.filter(id => BOOK_FONTS[id].category === category)
}
/**
* The CSS stack for a font id.
@@ -126,20 +59,20 @@ export function fontStack(id: string): string {
}
/** Whether a family actually ships the weight being asked for. */
export function hasWeight(id: string, weight: number): boolean {
const font = BOOK_FONTS[id as BookFontId]
return font ? font.weights.includes(weight) : true
}
export const hasWeight = bookFontHasWeight
/** Whether a family has a real italic at that weight, rather than the browser's slant. */
export const hasItalic = bookFontHasItalic
/**
* The nearest weight a family really has.
*
* A synthesised bold is a smeared regular in print, so a family with one weight
* gets that weight rather than a request the renderer has to fake.
* gets that weight rather than a request the renderer has to fake. The
* renderer draws every element through this as well, so a book whose weight
* was set before its family changed still prints in a face that exists.
*/
export function nearestWeight(id: string, weight: number): number {
const font = BOOK_FONTS[id as BookFontId]
if (!font || font.weights.includes(weight)) return weight
return font.weights.reduce((best, w) =>
(Math.abs(w - weight) < Math.abs(best - weight) ? w : best), font.weights[0])
}
export const nearestWeight = nearestBookWeight
/** The lighter weight a travel element sets beside its figures, one the family has. */
export const lighterWeight = bookLighterWeight
@@ -0,0 +1,47 @@
import { BOOK_FONT_CATEGORIES, type BookFontCategory, type BookWeight } from '@trek/shared';
import { BOOK_FONTS, fontsIn, hasItalic, nearestWeight, type BookFontId } from './bookFonts';
/**
* The type controls' decisions, apart from their markup: which families a
* search finds, the weight a newly picked family is set at, and what the row
* and the italic button say. StudioFontPicker.tsx and StudioTypeControls.tsx
* draw them.
*/
export type FontT = (key: string, params?: Record<string, string | number>) => string;
export interface FontPick {
font: BookFontId;
weight: BookWeight;
}
/** The family, its weight moved onto one the new family has. */
export function pickFont(id: BookFontId, weight: number): FontPick {
// A family that does not ship this weight would render a synthesised bold,
// a smeared regular in print, so the weight moves to the nearest one it has.
return { font: id, weight: nearestWeight(id, weight) as BookWeight };
}
/** The families that match a search, by name, id or group, in picker order. */
export function matchFonts(query: string, groupName: (c: BookFontCategory) => string): BookFontId[] {
const q = query.trim().toLowerCase();
return BOOK_FONT_CATEGORIES.flatMap((category) => {
const group = groupName(category).toLowerCase();
return fontsIn(category).filter(
(id) => !q || BOOK_FONTS[id].name.toLowerCase().includes(q) || id.includes(q) || group.includes(q)
);
});
}
/** The weights a family has, as its row says them: one number, or the first and the last. */
export function weightSpan(id: BookFontId, t: FontT): string {
const ws = BOOK_FONTS[id].weights;
const first = ws[0] ?? 400;
const last = ws[ws.length - 1] ?? first;
return first === last ? String(first) : t('journey.studio.fonts.range', { from: first, to: last });
}
/** The italic button's words: plain where the family has a real italic, the reason where the browser slants it. */
export function italicTitle(font: string, weight: number, t: FontT): string {
return hasItalic(font, nearestWeight(font, weight)) ? t('journey.studio.italic') : t('journey.studio.italicSlanted');
}
@@ -1,4 +1,5 @@
import { ASSISTANT_PAGE_IMAGE, type BookPageSetup, type BookSpread } from '@trek/shared';
import { snapshotFontCss } from './snapshotFonts';
/**
* One Studio page as a JPEG, for the assistant to look at (journey-studio.md
@@ -126,6 +127,8 @@ export async function snapshotSpread(
hiddenPhotoIds: ReadonlySet<number>
): Promise<PageSnapshot | null> {
await picturesLoaded(root, hiddenPhotoIds);
// Only the faces the page draws travel with it (snapshotFonts.ts); without any, the library looks for itself.
const fontCss = await snapshotFontCss(root);
// A single page is drawn up to the long edge too: a larger scale renders the page sharper, it stretches no pixels.
const scale = ASSISTANT_PAGE_IMAGE.longEdge / Math.max(size.width, size.height);
const { domToBlob } = await import('modern-screenshot');
@@ -137,6 +140,7 @@ export async function snapshotSpread(
quality: ASSISTANT_PAGE_IMAGE.jpegQuality,
filter: (node) => keepNode(node, hiddenPhotoIds),
timeout: LOAD_TIMEOUT_MS,
...(fontCss ? { font: { cssText: fontCss } } : {}),
fetch: { requestInit: { credentials: 'same-origin', cache: 'force-cache' } },
});
if (image.size > ASSISTANT_PAGE_IMAGE.maxBytes) return null;
@@ -0,0 +1,190 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
coversAny,
cssWeight,
faceRulesOf,
faceUsesOf,
parseFaceRule,
parseUnicodeRange,
pickFaceRules,
snapshotFontCss,
type FaceRule,
} from './snapshotFonts';
/**
* The faces a page snapshot carries (FE-STUDIO-SNAPFONT): what the page draws
* is read off the DOM, the browser's choice of rule is repeated, and only the
* faces it would pick go into the image, each with its file inline.
*/
const BASE = 'https://trek.example/assets/app.css';
const LATIN = 'U+0000-00FF, U+0131, U+0152-0153';
const EXT = 'U+0100-02BA, U+1E00-1E9F';
const rule = (family: string, weight: number, italic: boolean, range: string, file: string): FaceRule => ({
family,
weight,
italic,
unicodeRange: range,
url: `https://trek.example/assets/${file}`,
});
const RULES = [
rule('Lora', 400, false, LATIN, 'lora-latin-400-normal.woff2'),
rule('Lora', 400, false, EXT, 'lora-latin-ext-400-normal.woff2'),
rule('Lora', 700, false, LATIN, 'lora-latin-700-normal.woff2'),
rule('Lora', 400, true, LATIN, 'lora-latin-400-italic.woff2'),
rule('Caveat', 400, false, LATIN, 'caveat-latin-400-normal.woff2'),
rule('Anton', 400, false, LATIN, 'anton-latin-400-normal.woff2'),
];
const chars = (s: string) => new Set([...s].map((c) => c.codePointAt(0) ?? 0));
const files = (picked: FaceRule[]) => picked.map((r) => r.url.split('/').pop()).sort();
function page(html: string): HTMLElement {
const root = document.createElement('div');
root.innerHTML = html;
document.body.appendChild(root);
return root;
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('reading a rule', () => {
it('FE-STUDIO-SNAPFONT-001: reads family, weight, slant, range and the woff2 file of an @font-face rule', () => {
const css =
"@font-face { font-family: 'Lora'; font-style: italic; font-display: swap; font-weight: 700; " +
'src: url(./files/lora-latin-700-italic.woff2) format("woff2"), url(./files/lora-latin-700-italic.woff) format("woff"); ' +
`unicode-range: ${LATIN}; }`;
expect(parseFaceRule(css, BASE)).toEqual({
family: 'Lora',
weight: 700,
italic: true,
unicodeRange: LATIN,
url: 'https://trek.example/assets/files/lora-latin-700-italic.woff2',
});
const plain = '@font-face { font-family: "Anton"; src: url("/assets/anton.woff") format("woff"); }';
expect(parseFaceRule(plain, BASE)).toMatchObject({
weight: 400,
italic: false,
unicodeRange: '',
url: 'https://trek.example/assets/anton.woff',
});
expect(parseFaceRule('.x { color: red; }', BASE)).toBeNull();
expect(parseFaceRule('@font-face { font-family: "NoFile"; }', BASE)).toBeNull();
expect(parseFaceRule('@font-face { src: url(a.woff2); }', BASE)).toBeNull();
});
it('FE-STUDIO-SNAPFONT-002: reads weights and unicode ranges as CSS does', () => {
expect([cssWeight('bold'), cssWeight('normal'), cssWeight('300'), cssWeight('5000')]).toEqual([700, 400, 300, 400]);
expect(parseUnicodeRange('U+0000-00FF, U+0131, U+4??')).toEqual([
[0, 0xff],
[0x131, 0x131],
[0x400, 0x4ff],
]);
expect(parseUnicodeRange('U+zz, ')).toEqual([]);
expect(coversAny(LATIN, chars('Lisboa'))).toBe(true);
expect(coversAny(EXT, chars('Lisboa'))).toBe(false);
expect(coversAny(EXT, chars('Łódź'))).toBe(true);
expect(coversAny('', chars('anything'))).toBe(true);
});
});
describe('choosing the faces', () => {
it('FE-STUDIO-SNAPFONT-003: picks one rule per subset the text reaches, at the weight and slant it is set in', () => {
const uses = new Map([
['lora|700|false', chars('Porto')],
['caveat|400|false', chars('travel')],
]);
expect(files(pickFaceRules(RULES, uses))).toEqual(['caveat-latin-400-normal.woff2', 'lora-latin-700-normal.woff2']);
// Polish needs the extended subset beside the basic one.
const polish = new Map([['lora|400|false', chars('Kraków Łódź')]]);
expect(files(pickFaceRules(RULES, polish))).toEqual([
'lora-latin-400-normal.woff2',
'lora-latin-ext-400-normal.woff2',
]);
});
it('FE-STUDIO-SNAPFONT-004: falls back the way the browser does: the nearest weight, the upright face, nothing for an unknown family', () => {
expect(files(pickFaceRules(RULES, new Map([['lora|600|false', chars('a')]])))).toEqual([
'lora-latin-700-normal.woff2',
]);
expect(files(pickFaceRules(RULES, new Map([['lora|400|true', chars('a')]])))).toEqual([
'lora-latin-400-italic.woff2',
]);
expect(files(pickFaceRules(RULES, new Map([['anton|400|true', chars('a')]])))).toEqual([
'anton-latin-400-normal.woff2',
]);
expect(pickFaceRules(RULES, new Map([['georgia|400|false', chars('a')]]))).toEqual([]);
});
});
describe('reading the page', () => {
it('FE-STUDIO-SNAPFONT-005: reads every face the text under the root is set in, with its characters', () => {
const root = page(
'<p style="font-family: \'Lora\', Georgia, serif; font-weight: 700">Porto</p>' +
'<svg><text style="font-family: Caveat, cursive; font-style: italic">ab</text></svg>' +
'<p style="font-family: Lora; font-weight: bold">Té</p>' +
'<p style="font-family: Anton"> </p>'
);
const uses = faceUsesOf(root);
expect([...uses.keys()].sort()).toEqual(['caveat|400|true', 'lora|700|false']);
expect(uses.get('lora|700|false')).toEqual(chars('PortoTé'));
});
it('FE-STUDIO-SNAPFONT-006: reads the face rules of the stylesheets it may read, inside grouping rules too', () => {
const face = (family: string) => ({
cssText: `@font-face { font-family: "${family}"; src: url(/assets/${family}.woff2) format("woff2"); }`,
});
const fake = {
baseURI: 'https://trek.example/',
styleSheets: [
{
href: null,
cssRules: [face('Lora'), { cssText: '.x{}' }, { cssText: '@media print {}', cssRules: [face('Anton')] }],
},
{
href: 'https://cdn.example/a.css',
get cssRules(): never {
throw new DOMException('cross origin', 'SecurityError');
},
},
],
} as unknown as Document;
expect(faceRulesOf(fake).map((r) => [r.family, r.url])).toEqual([
['Lora', 'https://trek.example/assets/Lora.woff2'],
['Anton', 'https://trek.example/assets/Anton.woff2'],
]);
});
});
describe('the snapshot css', () => {
it('FE-STUDIO-SNAPFONT-007: inlines exactly the faces the page draws, and leaves out one that will not load', async () => {
const root = page(
'<p style="font-family: Lora; font-weight: 700">Porto</p><p style="font-family: Caveat">travel</p>'
);
const load = vi.fn(async (url: string) =>
url.includes('caveat') ? null : new Blob(['font'], { type: 'font/woff2' })
);
const css = await snapshotFontCss(root, load, RULES);
expect(load.mock.calls.map(([url]) => url.split('/').pop()).sort()).toEqual([
'caveat-latin-400-normal.woff2',
'lora-latin-700-normal.woff2',
]);
expect(css).toContain('@font-face{font-family:"Lora";font-style:normal;font-weight:700;unicode-range:U+0000-00FF');
expect(css).toContain('src:url(data:font/woff2;base64,');
expect(css).not.toContain('Caveat');
});
it('FE-STUDIO-SNAPFONT-008: answers null when the page draws no bundled face or none of its files load, so the library looks for itself', async () => {
const root = page('<p style="font-family: Georgia">Porto</p>');
expect(await snapshotFontCss(root, vi.fn(), RULES)).toBeNull();
const lora = page('<p style="font-family: Lora">Porto</p>');
const failing = vi.fn(async () => {
throw new Error('offline');
});
expect(await snapshotFontCss(lora, failing, RULES)).toBeNull();
});
});
@@ -0,0 +1,233 @@
/**
* The typefaces a page snapshot for the assistant carries, and only those.
*
* modern-screenshot draws the page as an SVG image, and an SVG image cannot
* reach the document's fonts, so every face it shows has to travel inside it.
* Left to itself the library embeds every @font-face rule of every family the
* page names: all nine weights of Montserrat, both styles, every script
* subset, woff and woff2. With thirty four book families that is megabytes of
* fonts per page for a picture whose text uses one weight of two families.
*
* So the snapshot reads what the page really draws (family, weight, slant and
* the characters set in it), picks the one rule per subset the browser would
* pick for that, and hands the library exactly those faces as data URLs. A
* page set in Lora and Caveat embeds the latin files of Lora and Caveat, and
* nothing else.
*/
/** One @font-face rule, as far as the choice of face goes. */
export interface FaceRule {
family: string;
weight: number;
italic: boolean;
/** The CSS unicode-range, or '' for a face that covers everything. */
unicodeRange: string;
/** The woff2 file, else the first file the rule names, resolved to a full URL. */
url: string;
}
/** What the page draws in one face: the characters set in it. */
type FaceUses = Map<string, Set<number>>;
const faceKey = (family: string, weight: number, italic: boolean) => `${family.toLowerCase()}|${weight}|${italic}`;
const unquote = (v: string) => v.trim().replace(/^["']|["']$/g, '');
/** A rule's descriptors by name, read from its text: `font-weight: 700` and the like. */
function descriptors(css: string): Map<string, string> {
const body = css.slice(css.indexOf('{') + 1, css.lastIndexOf('}'));
const out = new Map<string, string>();
for (const part of body.split(';')) {
const at = part.indexOf(':');
if (at > 0) out.set(part.slice(0, at).trim().toLowerCase(), part.slice(at + 1).trim());
}
return out;
}
/** A CSS weight as a number: the keywords as CSS reads them, anything else as 400. */
export function cssWeight(value: string): number {
if (value === 'bold') return 700;
const n = Number.parseInt(value, 10);
return Number.isFinite(n) && n >= 1 && n <= 1000 ? n : 400;
}
/** The file a rule's src names, the woff2 one first, resolved against where the stylesheet came from. */
function sourceOf(src: string, base: string): string | null {
const urls = [...src.matchAll(/url\((["']?)([^"')]+)\1\) ?(?:format\(["']?([\w-]+)["']?\))?/g)];
const pick = urls.find((m) => m[3] === 'woff2') ?? urls[0];
if (!pick?.[2]) return null;
try {
return new URL(pick[2], base).href;
} catch {
// A source no URL can be made of is a face the snapshot does without.
return null;
}
}
/** One @font-face rule read from its text, or null for any other rule or one that names no file. */
export function parseFaceRule(css: string, base: string): FaceRule | null {
if (!css.trimStart().toLowerCase().startsWith('@font-face')) return null;
const d = descriptors(css);
const family = unquote(d.get('font-family') ?? '');
const url = sourceOf(d.get('src') ?? '', base);
if (!family || !url) return null;
return {
family,
weight: cssWeight(d.get('font-weight') || '400'),
italic: /italic|oblique/i.test(d.get('font-style') ?? ''),
unicodeRange: d.get('unicode-range') ?? '',
url,
};
}
/** A unicode-range as code point spans: `U+0000-00FF, U+0131, U+4??` all read. */
export function parseUnicodeRange(range: string): [number, number][] {
return range
.split(',')
.map((part) => part.trim().replace(/^u\+/i, ''))
.filter(Boolean)
.flatMap((part): [number, number][] => {
if (part.includes('?')) {
return [[Number.parseInt(part.replace(/\?/g, '0'), 16), Number.parseInt(part.replace(/\?/g, 'F'), 16)]];
}
const [from, to] = part.split('-');
const a = Number.parseInt(from ?? '', 16);
const b = to === undefined ? a : Number.parseInt(to, 16);
return Number.isFinite(a) && Number.isFinite(b) ? [[a, b]] : [];
});
}
/** Whether a rule's range takes any of the characters; a rule without one takes them all. */
export function coversAny(range: string, chars: ReadonlySet<number>): boolean {
if (!range) return true;
const spans = parseUnicodeRange(range);
for (const c of chars) if (spans.some(([a, b]) => c >= a && c <= b)) return true;
return false;
}
/**
* The rules the browser would draw these uses with: per family and slant, the
* nearest weight it has (the renderer only ever asks for weights a family
* ships, so this is the exact one in practice), an upright face where there is
* no italic, and of those only the subsets whose range the text reaches.
*/
export function pickFaceRules(rules: readonly FaceRule[], uses: FaceUses): FaceRule[] {
const picked = new Map<string, FaceRule>();
for (const [key, chars] of uses) {
const [family = '', weightText = '400', italicText = 'false'] = key.split('|');
const weight = Number(weightText);
const ofFamily = rules.filter((r) => r.family.toLowerCase() === family);
const slanted = ofFamily.filter((r) => r.italic === (italicText === 'true'));
const styled = slanted.length ? slanted : ofFamily.filter((r) => !r.italic);
if (!styled.length) continue;
const nearest = styled.reduce((best, r) =>
Math.abs(r.weight - weight) < Math.abs(best.weight - weight) ? r : best
).weight;
for (const rule of styled) {
if (rule.weight === nearest && coversAny(rule.unicodeRange, chars)) picked.set(rule.url, rule);
}
}
return [...picked.values()];
}
/** The faces the text under `root` is set in, each with the characters it sets. */
export function faceUsesOf(root: Element): FaceUses {
const uses: FaceUses = new Map();
const view = root.ownerDocument.defaultView;
if (!view) return uses;
const walker = root.ownerDocument.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const words = node.textContent ?? '';
const parent = node.parentElement;
if (!parent || !words.trim()) continue;
const style = view.getComputedStyle(parent);
const family = unquote(style.fontFamily.split(',')[0] ?? '');
if (!family) continue;
const key = faceKey(family, cssWeight(style.fontWeight), /italic|oblique/.test(style.fontStyle));
const chars = uses.get(key) ?? new Set<number>();
for (const ch of words) {
const code = ch.codePointAt(0);
if (code !== undefined && code > 32) chars.add(code);
}
uses.set(key, chars);
}
return uses;
}
/** Every @font-face rule the document's stylesheets hold, inside grouping rules too. */
export function faceRulesOf(doc: Document): FaceRule[] {
const out: FaceRule[] = [];
const walk = (list: CSSRuleList, base: string) => {
for (const rule of Array.from(list)) {
const face = parseFaceRule(rule.cssText, base);
if (face) out.push(face);
else if ('cssRules' in rule) walk((rule as CSSGroupingRule).cssRules, base);
}
};
for (const sheet of Array.from(doc.styleSheets)) {
let rules: CSSRuleList;
try {
rules = sheet.cssRules;
} catch {
// A stylesheet from another origin cannot be read, and holds no book face.
continue;
}
walk(rules, sheet.href ?? doc.baseURI);
}
return out;
}
/** A file as a data URL. */
function asDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error ?? new Error('font read failed'));
reader.readAsDataURL(blob);
});
}
type FetchFont = (url: string) => Promise<Blob | null>;
/** A face file from this server, out of the cache when the page has already drawn it. */
const fetchFont: FetchFont = async (url) => {
if (new URL(url).origin !== window.location.origin) return null;
const res = await fetch(url, {
credentials: 'same-origin',
cache: 'force-cache',
signal: AbortSignal.timeout(8_000),
});
return res.ok ? res.blob() : null;
};
/** One face as a rule the snapshot can carry: its file inline. */
async function inlineRule(rule: FaceRule, load: FetchFont): Promise<string | null> {
try {
const blob = await load(rule.url);
if (!blob) return null;
const range = rule.unicodeRange ? `unicode-range:${rule.unicodeRange};` : '';
const family = rule.family.replace(/"/g, '');
const style = rule.italic ? 'italic' : 'normal';
return `@font-face{font-family:"${family}";font-style:${style};font-weight:${rule.weight};${range}src:url(${await asDataUrl(blob)});}`;
} catch {
// A face that cannot be fetched is drawn in its fallback; the page still goes.
return null;
}
}
/**
* The @font-face rules for the faces the page under `root` draws, with their
* files inline, ready for modern-screenshot's `font.cssText`. Null when there
* is nothing to carry, so the caller lets the library look for itself.
*/
export async function snapshotFontCss(
root: Element,
load: FetchFont = fetchFont,
available: readonly FaceRule[] = faceRulesOf(root.ownerDocument)
): Promise<string | null> {
const rules = pickFaceRules(available, faceUsesOf(root));
if (!rules.length) return null;
const inlined = await Promise.all(rules.map((rule) => inlineRule(rule, load)));
const css = inlined.filter((r): r is string => r !== null);
return css.length ? css.join('\n') : null;
}
@@ -1,7 +1,5 @@
import {
bikeCosting,
hikingCosting,
runCosting,
TOURS_MAX_HIKING_DIFFICULTY,
type BikeProfile,
type HikingPreferences,
@@ -80,6 +78,28 @@ const FIXED_LEG_KM_PER_HOUR = { ferrata: 1.5, free: 4, portage: 2 } as const
/** A leg drawn by hand on a bike tour is reckoned at an easy riding pace: no router saw it. */
const FREE_LEG_RIDING_KM_PER_HOUR = 15
/**
* BIK-01 to BIK-04: what each bike profile asks Valhalla's bicycle costing for.
* Every one was checked against the public instance with trace_attributes over
* the routes it gave, before it was offered (shares of the length):
* - touring, Hybrid with bad surfaces avoided: Garmisch station to the Eckbauer
* 92 % asphalt and no gravel, where gravel took 18 % gravel on farm tracks.
* - road, Road at full surface avoidance: Herrsching to Andechs 100 % asphalt
* over 6.2 km, where touring took 4.98 km with 7 % dirt and 10 % compacted.
* - gravel, Cross with surfaces barely avoided: Munich Marienplatz to the
* Englischer Garten 39 % compacted paths along the Isar, touring 9 %.
* - mtb, Mountain with no surface avoidance and roads least wanted: Garmisch
* to the Eckbauer 63 % tracks and 38 % gravel.
* `use_roads` and the bicycle type also set the router's riding speed, so the
* time it gives is the profile's. None of it says a way is rideable or safe.
*/
const BIKE_COSTING: Record<BikeProfile, Readonly<CostingOptions>> = {
touring: { bicycle_type: 'Hybrid', use_roads: 0.2, avoid_bad_surfaces: 1 },
road: { bicycle_type: 'Road', use_roads: 0.5, avoid_bad_surfaces: 1 },
gravel: { bicycle_type: 'Cross', use_roads: 0.1, avoid_bad_surfaces: 0.25 },
mtb: { bicycle_type: 'Mountain', use_roads: 0, avoid_bad_surfaces: 0 },
}
type LandWaypoint = Pick<Waypoint, 'lat' | 'lng'> & { fixedLeg?: TourFixedLeg | null }
/**
@@ -117,13 +137,43 @@ export async function routeCityWalk(
const CITY_WALK_COSTING: Readonly<CostingOptions> = { use_ferry: 0 }
const CITY_WALK_BINDING: Readonly<SearchFilter> = { exclude_ferry: true }
/**
* RUN-01, RUN-02: what each run profile asks Valhalla's pedestrian costing
* for. A run is run, so a ferry only where no way on land exists
* (`use_ferry` 0). Checked with trace_attributes against the public instance
* on 20.10.2026 (shares of the length):
* - running keeps to runnable, mostly paved ways: farm and forest tracks only
* where there is no other way (`use_tracks` 0), and no hiking limit, so the
* router keeps to the ways it takes for walking anyway (T1). Berlin S
* Grunewald into the forest went from 95 % tracks with 3 % paved to 67 %
* smooth paved paths over 3.58 km instead of 3.50 km; Munich Großhesseloher
* Brücke to Pullach from 79 % to 88 % smooth asphalt and no track left;
* Marienplatz to the Englischer Garten stayed as it was.
* - trail takes tracks wherever they serve (`use_tracks` 1) and admits
* mountain paths up to T2 (`max_hiking_difficulty` 2), never T3 and above.
* Above Freiburg Günterstal up to the Schauinsland it went from 82 % roads
* and 83 % paved (running) to 78 % forest tracks and 81 % compacted, gravel
* and dirt over 9.57 km instead of 10.15 km; Rathen to the Bastei took 28 % of T2
* path that running leaves out; Heidelberg's old town, all streets, stayed
* as it was. Leaning on footways (`walkway_factor`) was tried and left out:
* along the Isar it moved the run onto paved footways, from 33 % to 18 %
* unpaved.
* The router's own time is a walker's and is never used: a run's time is its
* distance at the runner's pace, whatever the profile. Nothing here says a
* way can be run.
*/
const RUN_COSTING: Record<RunProfile, Readonly<CostingOptions>> = {
running: { use_tracks: 0, use_ferry: 0 },
trail: { use_tracks: 1, use_ferry: 0, max_hiking_difficulty: 2 },
}
/** RUN-01, RUN-02: a run, on foot for its profile; a leg drawn by hand is kept as drawn. */
export async function routeRun(
waypoints: LandWaypoint[],
profile: RunProfile,
signal?: AbortSignal,
): Promise<TourRouteResult | TourRouteUnreachable | null> {
return routeOnLand(waypoints, 'walking', runCosting(profile), signal)
return routeOnLand(waypoints, 'walking', RUN_COSTING[profile], signal)
}
/** BIK-01: a bike tour, routed for its profile; a leg drawn by hand is kept as drawn. */
@@ -132,7 +182,7 @@ export async function routeBikeTour(
profile: BikeProfile,
signal?: AbortSignal,
): Promise<TourRouteResult | TourRouteUnreachable | null> {
return routeOnLand(waypoints, 'cycling', bikeCosting(profile), signal)
return routeOnLand(waypoints, 'cycling', BIKE_COSTING[profile], signal)
}
/**
+174
View File
@@ -0,0 +1,174 @@
/* ============================================================================
TREK Studio: the typeface picker and the weight slider.
Its own file beside studio.css, which is at its size ceiling. Same rules as
there: every selector is scoped under `.trek-dash` (dashboard.css resets
`button` at a specificity a bare class loses to), and every colour is one of
the dashboard tokens, so the picker follows the user's theme and accent.
The family names are set in their own faces and at the user's text size,
which is why they scale with --fs-scale-body rather than sitting at a fixed
pixel size: a picker is read, and a name too small to read is a name that
tells you nothing about its face.
============================================================================ */
.trek-dash .st-fontpick {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.trek-dash .st-fontpick-current,
.trek-dash .st-fontpick-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
text-align: start;
color: var(--ink);
transition:
background 0.14s,
border-color 0.14s,
color 0.14s;
}
.trek-dash .st-fontpick-current {
height: 40px;
padding-inline: 10px 8px;
border-radius: 8px;
border: 1px solid var(--line-2);
background: var(--surface);
}
.trek-dash .st-fontpick-current:hover {
border-color: color-mix(in oklch, var(--accent) 40%, var(--line-2));
}
.trek-dash .st-fontpick-current:focus-visible,
.trek-dash .st-fontpick-row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.trek-dash .st-fontpick.is-open .st-fontpick-current {
border-color: var(--accent);
}
.trek-dash .st-fontpick-face {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.25;
font-size: calc(16px * var(--fs-scale-body, 1));
}
.trek-dash .st-fontpick-span {
flex: none;
font-size: calc(11px * var(--fs-scale-caption, 1));
color: var(--ink-3);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.trek-dash .st-fontpick-caret {
flex: none;
color: var(--ink-3);
transition: transform 0.18s;
}
.trek-dash .st-fontpick.is-open .st-fontpick-caret {
transform: rotate(180deg);
}
/* The open list. It sits in the inspector's flow and scrolls on its own, so
the inspector around it keeps its place. */
.trek-dash .st-fontpick-panel {
display: flex;
flex-direction: column;
border-radius: 12px;
border: 1px solid var(--line);
background: color-mix(in oklch, var(--surface) 70%, transparent);
overflow: hidden;
}
.trek-dash .st-fontpick-search {
margin: 8px 8px 4px;
}
.trek-dash .st-fontpick-list {
max-height: 320px;
overflow-y: auto;
overscroll-behavior: contain;
padding: 2px 4px 6px;
}
.trek-dash .st-fontpick-group + .st-fontpick-group {
margin-top: 4px;
}
.trek-dash .st-fontpick-label {
position: sticky;
top: 0;
z-index: 1;
padding: 8px 8px 4px;
font-size: calc(10.5px * var(--fs-scale-caption, 1));
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-3);
background: color-mix(in oklch, var(--surface) 92%, transparent);
}
.trek-dash .st-fontpick-row {
min-height: 38px;
padding-inline: 8px;
border-radius: 8px;
color: var(--ink-2);
}
.trek-dash .st-fontpick-row:hover {
background: var(--surface);
color: var(--ink);
}
.trek-dash .st-fontpick-row.is-on {
color: var(--ink);
background: color-mix(in oklch, var(--accent) 10%, transparent);
}
.trek-dash .st-fontpick-tick {
flex: none;
width: 14px;
display: grid;
place-items: center;
color: var(--accent);
}
.trek-dash .st-fontpick-none {
padding: 10px 8px;
margin: 0;
}
/* ----------------- weight ----------------- */
.trek-dash .st-weight {
width: 100%;
}
.trek-dash .st-weight > .st-weight-value,
.trek-dash .st-weight > .st-weight-only {
font-size: calc(11.5px * var(--fs-scale-caption, 1));
color: var(--ink-2);
white-space: nowrap;
}
.trek-dash .st-weight > .st-weight-value {
flex: none;
min-width: 84px;
text-align: end;
font-variant-numeric: tabular-nums;
}
.trek-dash .st-weight > .st-weight-only {
flex: 1;
color: var(--ink-3);
}
@media (prefers-reduced-motion: reduce) {
.trek-dash .st-fontpick-caret,
.trek-dash .st-fontpick-current,
.trek-dash .st-fontpick-row {
transition: none;
}
}
+1 -31
View File
@@ -1583,37 +1583,7 @@
flex: none;
}
/* ----------------- font picker ----------------- *
* Each family set in itself. A row of chips reading "Poppins / Lora / Bebas" in
* the interface font tells you their names and nothing about how they look,
* which is the only thing anyone is choosing between.
*/
.trek-dash .st-fonts {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
}
.trek-dash .st-font {
height: 32px;
padding: 0 8px;
border-radius: 8px;
border: 1px solid var(--line);
background: color-mix(in oklch, var(--surface) 55%, transparent);
color: var(--ink-2);
font-size: 14px;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
transition: background .14s, border-color .14s, color .14s;
}
.trek-dash .st-font:hover { background: var(--surface); color: var(--ink); }
.trek-dash .st-font.is-on {
color: var(--ink);
border-color: color-mix(in oklch, var(--accent) 50%, transparent);
background: color-mix(in oklch, var(--accent) 10%, transparent);
}
/* The typeface picker lives in studio-fonts.css. */
.trek-dash .st-chip:disabled { opacity: .32; cursor: default; }
/* ----------------- save state ----------------- *
+10
View File
@@ -3,3 +3,13 @@
/** The version this bundle was built as (client/package.json), set by `define` in vite.config.js. */
declare const __TREK_UI_VERSION__: string;
/**
* Big Shoulders Display's weights, imported for their stylesheets alone
* (components/Studio/bookFontFaces.ts). Its package maps `./*` to `./*.css`
* and has no `./*.css` entry, so the import names `100` rather than
* `100.css`, and vite/client's `*.css` declaration does not cover a bare
* name. Declared for this one package only, so a mistyped import of any
* other module still fails the check.
*/
declare module '@fontsource/big-shoulders-display/*';
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { APP_FACES, bookFontFile, keepInPrecache, withoutRuntimeFonts } from '../../../bookFontPrecache.js';
/**
* Which font files the service worker installs (FE-SW-FONTS): the app's own
* faces as before, the latin woff2 of the seven book families that shipped
* first, and nothing else of the book's catalogue, which waits in the
* book-fonts runtime cache for a book that uses it.
*/
// Vitest runs with the client package as its root, so cwd is stable here.
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), 'utf8');
const asset = (name: string) => `assets/${name}`;
describe('reading a font file name', () => {
it('FE-SW-FONTS-001: reads package, subset, weight and style as the build names them, and nothing else as a book face', () => {
expect(bookFontFile(asset('dm-serif-display-latin-ext-400-italic-Ab12_cD3.woff2'))).toEqual({
pkg: 'dm-serif-display',
subset: 'latin-ext',
weight: 400,
italic: true,
ext: 'woff2',
});
expect(bookFontFile(asset('dm-mono-latin-500-normal-xyz.woff'))).toMatchObject({ pkg: 'dm-mono', subset: 'latin' });
expect(bookFontFile(asset('geist-sans-latin-400-normal-abc.woff2'))).toBeNull();
expect(bookFontFile(asset('index-abc.js'))).toBeNull();
expect(bookFontFile(asset('lora.woff2'))).toBeNull();
});
});
describe('the precache', () => {
it('FE-SW-FONTS-002: keeps the faces the app draws, whatever their subset and format', () => {
expect(keepInPrecache(asset('poppins-latin-500-normal-abc.woff'))).toBe(true);
expect(keepInPrecache(asset('poppins-devanagari-700-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('museomoderno-latin-800-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('geist-sans-latin-600-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('index-abc.js'))).toBe(true);
});
it('FE-SW-FONTS-003: keeps only the latin woff2 of the seven book families that shipped first', () => {
expect(keepInPrecache(asset('lora-latin-400-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('bebas-neue-latin-400-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('museomoderno-latin-500-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('lora-latin-400-normal-abc.woff'))).toBe(false);
expect(keepInPrecache(asset('lora-cyrillic-400-normal-abc.woff2'))).toBe(false);
expect(keepInPrecache(asset('lora-latin-400-italic-abc.woff2'))).toBe(false);
expect(keepInPrecache(asset('eb-garamond-latin-800-normal-abc.woff2'))).toBe(false);
expect(keepInPrecache(asset('poppins-latin-900-normal-abc.woff2'))).toBe(false);
});
it('FE-SW-FONTS-004: leaves every file of the new families to the runtime cache', () => {
for (const name of [
'anton-latin-400-normal-abc.woff2',
'caveat-latin-700-normal-abc.woff2',
'fraunces-latin-ext-900-italic-abc.woff',
]) {
expect(keepInPrecache(asset(name)), name).toBe(false);
}
const manifest = [
{ url: asset('index-abc.js'), revision: null },
{ url: asset('anton-latin-400-normal-abc.woff2'), revision: null },
{ url: asset('inter-latin-400-normal-abc.woff2'), revision: null },
];
expect(withoutRuntimeFonts(manifest)).toEqual({ manifest: [manifest[0], manifest[2]], warnings: [] });
});
it('FE-SW-FONTS-005: knows the app faces main.tsx imports, so the chrome never loses one offline', () => {
const imported = [...read('src/main.tsx').matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+)\.css/g)].map(
(m) => `${m[1]}/${m[2]}`
);
const declared = Object.entries(APP_FACES).flatMap(([pkg, weights]) => weights.map((w) => `${pkg}/${w}`));
expect(imported.filter((face) => !face.startsWith('geist-sans/')).sort()).toEqual(declared.sort());
});
it('FE-SW-FONTS-006: is wired into the worker, with the runtime rule the Studio fills', () => {
const config = read('vite.config.js');
expect(config).toContain('manifestTransforms: [withoutRuntimeFonts]');
const rule =
/urlPattern:\s*(\/[^\n]*woff2[^\n]*\/i),\s*\n\s*handler:\s*'CacheFirst',\s*\n\s*options:\s*\{\s*\n\s*cacheName:\s*'book-fonts'/.exec(
config
);
expect(rule).not.toBeNull();
const literal = rule?.[1] ?? '//';
const pattern = new RegExp(literal.slice(1, literal.lastIndexOf('/')), 'i');
expect(pattern.test('https://trek.example/assets/anton-latin-400-normal-abc.woff2')).toBe(true);
expect(pattern.test('https://trek.example/assets/lora-latin-400-normal-abc.woff')).toBe(true);
expect(pattern.test('https://trek.example/assets/index-abc.js')).toBe(false);
expect(pattern.test('https://trek.example/api/photos/1/file.woff2')).toBe(false);
});
});
+19 -9
View File
@@ -23,30 +23,40 @@ const STUDIO = 'src/components/Studio/bookFontFaces.ts'
// Vitest runs with the client package as its root, so cwd is stable here.
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), 'utf8')
/** Every `@fontsource/<package>/<weight>.css` a file imports. */
/** Every `@fontsource/<package>/<weight>[-italic][.css]` a file imports (see bookFontFaces.ts for the bare form). */
function facesIn(rel: string): string[] {
return [...read(rel).matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+)\.css/g)]
return [...read(rel).matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+(?:-italic)?)(?:\.css)?'/g)]
.map(m => `${m[1]}/${m[2]}`)
}
/** @fontsource names its packages after the family, lowercased and hyphenated. */
function faceOf(id: BookFontId, weight: number): string {
return `${BOOK_FONTS[id].name.toLowerCase().replace(/ /g, '-')}/${weight}`
/** A face as the import names it: the family's package, the weight and the italic. */
function faceOf(id: BookFontId, weight: number, italic = false): string {
return `${BOOK_FONTS[id].pkg}/${weight}${italic ? '-italic' : ''}`
}
/** Every face the catalogue declares: each weight upright and each real italic. */
const declared = BOOK_FONTS_IDS.flatMap(id => [
...BOOK_FONTS[id].weights.map(w => faceOf(id, w)),
...BOOK_FONTS[id].italics.map(w => faceOf(id, w, true)),
])
describe('the bundled faces', () => {
it('loads every family the picker offers, in the weights it offers', () => {
const loaded = [...facesIn(APP), ...facesIn(STUDIO)]
for (const face of declared) {
expect(loaded, face).toContain(face)
}
})
/* @fontsource names its packages after the family, lowercased and hyphenated. */
it('takes every family from the package named after it', () => {
for (const id of BOOK_FONTS_IDS) {
for (const weight of BOOK_FONTS[id].weights) {
expect(loaded, `${BOOK_FONTS[id].name} ${weight}`).toContain(faceOf(id, weight))
}
expect(BOOK_FONTS[id].pkg, id).toBe(BOOK_FONTS[id].name.toLowerCase().replace(/ /g, '-'))
}
})
/* A weight nothing can ask for is a download nobody wanted. */
it('loads no weight the registry does not declare', () => {
const declared = BOOK_FONTS_IDS.flatMap(id => BOOK_FONTS[id].weights.map(w => faceOf(id, w)))
for (const face of facesIn(STUDIO)) {
expect(declared, face).toContain(face)
}
+15 -5
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import { BOOK_FONTS_IDS, bookTextElementSchema } from '@trek/shared'
import {
BOOK_FONTS, BOOK_FONT_ORDER, fontStack, hasWeight, nearestWeight,
BOOK_FONTS, BOOK_FONT_ORDER, fontStack, fontsIn, hasItalic, hasWeight, nearestWeight,
} from '../../../src/components/Studio/bookFonts'
/**
@@ -21,9 +21,13 @@ describe('the registry', () => {
}
})
it('offers every family in the picker, in one order', () => {
it('offers every family in the picker, in one order, grouped by category', () => {
expect([...BOOK_FONT_ORDER].sort()).toEqual([...BOOK_FONTS_IDS].sort())
expect(new Set(BOOK_FONT_ORDER).size).toBe(BOOK_FONT_ORDER.length)
expect(BOOK_FONT_ORDER[0]).toBe('sans')
expect(BOOK_FONT_ORDER[BOOK_FONT_ORDER.length - 1]).toBe('mono')
expect(fontsIn('script')).toContain('caveat')
expect(fontsIn('script').every(id => BOOK_FONTS[id].category === 'script')).toBe(true)
})
/*
@@ -34,17 +38,17 @@ describe('the registry', () => {
it('ends every stack in a generic family', () => {
for (const id of BOOK_FONTS_IDS) {
const last = BOOK_FONTS[id].stack.split(',').pop()!.trim()
expect(['sans-serif', 'serif', 'monospace'], `${id}: ${last}`).toContain(last)
expect(['sans-serif', 'serif', 'cursive', 'monospace'], `${id}: ${last}`).toContain(last)
}
})
it('names a bundled family first in every stack', () => {
// The first entry is the one that will actually be used, so it is the one
// that has to be self-hosted.
const bundled = ['Poppins', 'Inter', 'Lora', 'EB Garamond', 'Playfair Display', 'MuseoModerno', 'Bebas Neue']
// bookFontFaces.test.ts holds every family to the package it is loaded from.
for (const id of BOOK_FONTS_IDS) {
const first = BOOK_FONTS[id].stack.split(',')[0].replace(/"/g, '').trim()
expect(bundled, `${id}: ${first}`).toContain(first)
expect(first, id).toBe(BOOK_FONTS[id].name)
}
})
@@ -98,6 +102,12 @@ describe('weights', () => {
it('moves to the nearest weight a family really has', () => {
expect(nearestWeight('bebas', 700)).toBe(400)
expect(nearestWeight('sans', 700)).toBe(700)
expect(nearestWeight('oswald', 900)).toBe(700)
})
it('knows which families have a real italic', () => {
expect(hasItalic('garamond', 400)).toBe(true)
expect(hasItalic('sans', 400)).toBe(false)
})
it('leaves an unknown family alone rather than guessing', () => {
@@ -101,6 +101,20 @@ const tick = (name: string) => fireEvent.click(screen.getByRole('checkbox', { na
/** A switch, or any plain button: both answer to their accessible name. */
const press = (name: string) => fireEvent.click(screen.getByRole('button', { name }))
/** Opens the typeface list from its button and picks a family by the name its row is set in. */
function pickFamily(name: string) {
fireEvent.click(screen.getByRole('button', { name: /^journey\.studio\.fonts\.change: / }))
const row = screen.getAllByRole('option').find(o => o.querySelector('.st-fontpick-face')?.textContent === name)
if (!row) throw new Error(`no typeface row named ${name}`)
fireEvent.click(row)
}
/** Drags the weight slider to the stop of a weight among those the family ships. */
function slideWeight(weights: readonly number[], weight: number) {
const slider = screen.getByRole('slider', { name: 'journey.studio.weight' })
fireEvent.change(slider, { target: { value: String(weights.indexOf(weight)) } })
}
/** A section by its heading, since a label can repeat across sections. */
function section(container: HTMLElement, label: string): HTMLElement {
const found = [...container.querySelectorAll<HTMLElement>('.st-section')]
@@ -512,7 +526,7 @@ describe('the tail all five share', () => {
// Bebas ships one weight; a bold it does not have prints as a smeared
// regular, so the request moves rather than being passed through.
press('Bebas Neue')
pickFamily('Bebas Neue')
expect(saved().font).toBe('bebas')
expect(saved().weight).toBe(400)
@@ -522,7 +536,7 @@ describe('the tail all five share', () => {
const { container } = show(statsEl({ font: 'sans', weight: 500 }))
open(container, 'journey.studio.typography')
press('Lora')
pickFamily('Lora')
expect(saved().font).toBe('serif')
expect(saved().weight).toBe(500)
@@ -532,7 +546,8 @@ describe('the tail all five share', () => {
const { container } = show(countriesEl())
open(container, 'journey.studio.typography')
pick('500')
// Poppins ships 100 to 900, so 500 is the fifth stop.
slideWeight([100, 200, 300, 400, 500, 600, 700, 800, 900], 500)
expect(saved().weight).toBe(500)
})
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import type { BookDocument, BookRecord } from '@trek/shared'
import { BOOK_SCHEMA_REV, type BookDocument, type BookRecord } from '@trek/shared'
/**
* Autosaving a Studio book (#1973).
@@ -851,13 +851,13 @@ describe('a book a newer Studio saved', () => {
expect(result.current.state.status).toBe('newer')
})
/* A book of today goes out exactly as it went out before the revision existed. */
it('FE-STUDIO-REV-006: sends a book of today without a revision in it', async () => {
/* A book goes out stamped with the revision of the Studio that wrote it, so an older one cannot save over it. */
it('FE-STUDIO-REV-006: sends a book stamped with the revision of this build', async () => {
const { result } = await mount()
act(() => { result.current.queueSave(doc('a'), 'T') })
await tick()
expect(api.saveBook.mock.calls[0][1].document).not.toHaveProperty('schemaRev')
expect(api.saveBook.mock.calls[0][1].document.schemaRev).toBe(BOOK_SCHEMA_REV)
})
})
+21
View File
@@ -3,6 +3,7 @@ import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import { visualizer } from 'rollup-plugin-visualizer';
import { rtlTextAlias, plyrSpriteAlias } from './rtlTextAlias.js';
import { withoutRuntimeFonts } from './bookFontPrecache.js';
import { readFileSync } from 'node:fs';
// The version this bundle is built as, baked in at build time. The Docker build
@@ -77,6 +78,11 @@ export default defineConfig(({ mode }) => ({
// matching the globs below is fetched at service-worker install by every
// user, whether or not they ever reach the code.
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
// Except most of the book's typefaces: of thirty four families only the
// latin files of the seven the book first shipped with are installed,
// the rest wait in the book-fonts rule below for a book that uses them
// (bookFontPrecache.js).
manifestTransforms: [withoutRuntimeFonts],
// build:analyze drops a treemap next to the app; it must never end up in a
// precache manifest if someone ships that build by accident.
// The assistant's chat (@assistant-ui, about 110 kB gzip) only runs online, so
@@ -92,6 +98,21 @@ export default defineConfig(({ mode }) => ({
/^\/plugin-frame\//,
],
runtimeCaching: [
{
// The book's typefaces the precache leaves out (bookFontPrecache.js):
// fetched the first time a page is set in them, and up front for
// every face of a book the Studio opens (bookFontWarm.ts), so the
// book still reads in its own type offline. Same-origin build
// assets only; the files are content-hashed, so a cached one never
// goes stale. First in the list so no broader rule takes them.
urlPattern: /\/assets\/[^/?#]+\.woff2?(?:\?.*)?$/i,
handler: 'CacheFirst',
options: {
cacheName: 'book-fonts',
expiration: { maxEntries: 600, maxAgeSeconds: 365 * 24 * 60 * 60 },
cacheableResponse: { statuses: [200] },
},
},
{
// Carto map tiles (default provider)
// maxEntries MUST stay >= MAX_TILES in src/sync/tilePrefetcher.ts
+270
View File
@@ -33,14 +33,41 @@
"hasInstallScript": true,
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
"@fontsource/alfa-slab-one": "^5.3.0",
"@fontsource/allura": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/big-shoulders-display": "^5.3.0",
"@fontsource/bodoni-moda": "^5.3.0",
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/caveat": "^5.3.0",
"@fontsource/cormorant-garamond": "^5.3.0",
"@fontsource/dancing-script": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0",
"@fontsource/dm-serif-display": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/homemade-apple": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/oswald": "^5.3.0",
"@fontsource/pacifico": "^5.3.0",
"@fontsource/parisienne": "^5.3.0",
"@fontsource/permanent-marker": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@fontsource/sacramento": "^5.3.0",
"@fontsource/satisfy": "^5.3.0",
"@fontsource/shrikhand": "^5.3.0",
"@fontsource/yellowtail": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
@@ -3366,6 +3393,51 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@fontsource/abril-fatface": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/abril-fatface/-/abril-fatface-5.3.0.tgz",
"integrity": "sha512-voKCJEhGGZNdlk+OS3uFRSDV/HWbs6TAy7qKe9wXq96Uzwadfksyk5eyXLa1T5FkEazBgpHRMRSFBKfA3rbKbg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/alfa-slab-one": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/alfa-slab-one/-/alfa-slab-one-5.3.0.tgz",
"integrity": "sha512-J/ANFZ7S9CbZClr/QhMjmBtpFmmKipVkEXHj20aurR5uqJPCBChVPJn1/kx6vtrwnfnDY0kQaYSTYasSt4GruA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/allura": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/allura/-/allura-5.3.0.tgz",
"integrity": "sha512-UqloNNVxl03+psvtIXCYuo7/v2mD9XmX2HGiczhK6NmDDwbFJJDjrbHZ5tpNuVXKsMEQPc46tZu3hewYN82/YA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/anton": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/anton/-/anton-5.3.0.tgz",
"integrity": "sha512-OR1D5n124n2XRs66SbX7IjcOSptljEJE8FcDNnlni2y1/M6KpTx7cU2PIWzTSsBj4WFkHPw24On8qw3qkQZuTw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/archivo-black": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/archivo-black/-/archivo-black-5.3.0.tgz",
"integrity": "sha512-7EsTIQ+Blf96ktpzTUpI7msi+u9/AJxM/GZxOSkEubVd1jSTrFSTNxMgfpX/8rQsjZ+19a+qvRbtcTEp5J/72g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bebas-neue": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.3.0.tgz",
@@ -3375,6 +3447,78 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/big-shoulders-display": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/big-shoulders-display/-/big-shoulders-display-5.3.0.tgz",
"integrity": "sha512-d4+Tcsq/WfoKeqWfB6wFdeNDsSYYnilJbSNNpf5w53MlWrGHAc9YvkAjC6id6PgFDiGaFKsXfP0RFAQtpt0bog==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bodoni-moda": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/bodoni-moda/-/bodoni-moda-5.3.0.tgz",
"integrity": "sha512-Wt2XBCHivYxkTP3ydq/Ax+7VshwsOh/6ayA5om2t+yClCkGwLPj2Mi6OnyMANjyLcqDUWs4rlOHnc8SBLf6/pA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bricolage-grotesque": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz",
"integrity": "sha512-MdOVb/5in11IfN/IQJOExMuu6AroCIQbirl0yX7NUvKk+h0HYIKfGaVAXRS/gCPil4aFxWhjZnOw28tUwv7tFw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/caveat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.3.0.tgz",
"integrity": "sha512-eHTfzQxFSW9ABERRLNruHoNJmKed2J7pqTUc5lp4a4POMgXGKpxmWj3d9Hz10kmAg6iuTTLe6UagbA4bnsmfQw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/cormorant-garamond": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/cormorant-garamond/-/cormorant-garamond-5.3.0.tgz",
"integrity": "sha512-weuGsCirGVWBWqpt6YUp0yLThTe4G8YO45noq8wxeJCRvEpq5lFrxNMFSTxcyOyV5k5otzJ8guDzYegdYlcLMQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dancing-script": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/dancing-script/-/dancing-script-5.3.0.tgz",
"integrity": "sha512-/FFbz4ZCNN+eG2a6phkZB41iKmyCC6rq2blA/kG9OFeI3/tpzLNazND1bBdEnogLhpfg7o4NfauTbEEYSpSUGg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dm-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/dm-mono/-/dm-mono-5.3.0.tgz",
"integrity": "sha512-OINjI8C1S/wpchhQxl7njZdMn4+hnDCpQ4YtvvOpKNARo+0J8O1x1IcrChxNjHOhfVv1by8C/FQoy3hXK+C1Ug==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dm-serif-display": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/dm-serif-display/-/dm-serif-display-5.3.0.tgz",
"integrity": "sha512-ecRclUcYeto9fB7ghkB36H4MtUoaUYFjjornWYYeLCMHzaWEDPk2kIWgZOq0mKBNRxclRSieKcFeLonlMYDTKg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/eb-garamond": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/eb-garamond/-/eb-garamond-5.3.0.tgz",
@@ -3384,6 +3528,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/fraunces": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/fraunces/-/fraunces-5.3.0.tgz",
"integrity": "sha512-LEHTOBiZufQzAvnAe2rZ8ZknbDS28Q9kzwb0L+gZJEmjLGr8hhZ/5NuHXwDuHBWhVXYoiGGXmrb0H6Ko/RrHTg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/geist-sans": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.3.0.tgz",
@@ -3393,6 +3546,24 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/great-vibes": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/great-vibes/-/great-vibes-5.3.0.tgz",
"integrity": "sha512-1w60cXzXnC9V4y943po8044O1mqRQBiTTK75h7OpZE+1VQtx+KlAAzoIyEE5KNzu05gUpxaCQg7cbL7ITSwegQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/homemade-apple": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/homemade-apple/-/homemade-apple-5.3.0.tgz",
"integrity": "sha512-awJSYTbSY3SroC8nirEhDauHvERVvm0LI3FpUHsPZXl9Tht1xrqhiI8ro4yf3ROcvQ7CfFJ/2+DLcOHBOXhJKw==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
@@ -3402,6 +3573,24 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/kaushan-script": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/kaushan-script/-/kaushan-script-5.3.0.tgz",
"integrity": "sha512-Kdcvr76wOGp2jgKkkmAOETmPcw/a34RU/qcGW2+gSKzkKyr03vEaLWSbSABoVKWuyuoqWoulcmC4571vq3BYvg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/league-gothic": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/league-gothic/-/league-gothic-5.3.0.tgz",
"integrity": "sha512-gqedetX+UlaiJgLiPnOzWGB1sY3OtNhS8YMjTy2D73AcsagIqpdIizssr1MStcUYf3D/zzanECuXawIgMGZxDg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/lora": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/lora/-/lora-5.3.0.tgz",
@@ -3411,6 +3600,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/montserrat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz",
"integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/museomoderno": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/museomoderno/-/museomoderno-5.3.0.tgz",
@@ -3420,6 +3618,42 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/oswald": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/oswald/-/oswald-5.3.0.tgz",
"integrity": "sha512-16r2aHActe0pH+9FLdk/74/YxXM7HpGak05kAzx3RvSToiJwGvyhv6GvckPz+YuZmvL1nxWbEop2jAXRcXYPRg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/pacifico": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/pacifico/-/pacifico-5.3.0.tgz",
"integrity": "sha512-bWv0fHR1n36p7scRn+maaEaVeQCOq9yvfVfjyAeUzKrK4Y/3qiRAEj4hJgfxj/sjgZZGkeHyKPrbWdDaZtpCVw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/parisienne": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/parisienne/-/parisienne-5.3.0.tgz",
"integrity": "sha512-YbfjZDYy7FMTVE0HRgejkGX3T5iFfgagOYtAAAymq9GklviFAZ1GqIdlyviNe23TiR20f2K7zxsL4AECtWnkEQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/permanent-marker": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/permanent-marker/-/permanent-marker-5.3.0.tgz",
"integrity": "sha512-ZO/NtYW4SCc/iY8N3dmjWyDITcXJ/vhWZFgnwRcvzwCKKnScMrM/a2/6VHlsjCVuIgmPzNrCFbedEYhv+/jJBg==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/playfair-display": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/playfair-display/-/playfair-display-5.3.0.tgz",
@@ -3438,6 +3672,42 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/sacramento": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/sacramento/-/sacramento-5.3.0.tgz",
"integrity": "sha512-KkqN52aTsJ0pSBva/NREqFljs0M1TwMIpNcqdSQc2EKR/mVU1CvM2dazf9eG19rHMTIssjbia83nGG705SlD1w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/satisfy": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/satisfy/-/satisfy-5.3.0.tgz",
"integrity": "sha512-dQ+Cy85XdwYnwlzWcbkmA2Qkpxam0ZHDaGm/qoGHlyHKRVgM/BJN8RLHpRm31R0REFBQe5Yl8Ke5oTdpcKdPIA==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/shrikhand": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/shrikhand/-/shrikhand-5.3.0.tgz",
"integrity": "sha512-AWCCj1oNNpMK5hXfFo4JO5KyLhPrdBmpbA0wyIl4IUB05WTU0CGP2UgGX7Lcq/N8nS2+hcxzCPMSQ1R55FuMPg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/yellowtail": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/yellowtail/-/yellowtail-5.3.0.tgz",
"integrity": "sha512-atz4CIdbP7otppxRaObJiygnkyJzIuLFpPRjWspXvXhBWLB0sk35cCu5r6gSkDomRtEWakJ8//bWbyBjJbilGg==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
@@ -28,8 +28,8 @@ The energy decides the rest:
- Vary the beats, never three alike in a row; a quiet page for a long story, grids for bursts.
Type scale, in points for a 210 mm page and scaled with the page width:
- Cover title in a display face, 72 to 180 pt, 12 to 30 % of the page height; title to subtitle at least 3:1. For a big cover raise type.display (up to 6.5, about 180 pt); keep type.scale between 0.9 and 1.1 so body stays 9 to 11 pt.
- Date lines in spaced caps, 7 to 9 pt, tracking 0.25 to 0.4; an accent word over a title in the contrasting face (playfair italic or display), 1.2 to 1.6 times its cap height, with contrast, an outline or a shadow.
- Body 9 to 11 pt; captions 6.5 to 8 pt with tracking 0.05 to 0.1; a display face and a body face, two typefaces at most.
- Date lines in spaced caps, 7 to 9 pt, tracking 0.25 to 0.4; an accent word over a title in a script (caveat, pacifico, vibes, marker) or the contrasting face (playfair italic), 1.2 to 1.6 times its cap height, with contrast, an outline or a shadow.
- Body 9 to 11 pt in a face whose role is body; captions 6.5 to 8 pt with tracking 0.05 to 0.1; a display face, a body face and one more at most, a script or a mono line not counted; a family sets only the weights its font id lists.
Look, judge, revise:
- Check every changed page with describe_book_page and the designId, and look at the pages that matter most with preview_book_page and the designId when you can see images; preview the cover at least twice, the first draft and the final one.
- Before every proposal answer silently: Is there one dominant element? Does the title read at thumbnail size? Does the energy match the brief? Is the palette the book's own? Is anything touching or overlapping by accident? Is text on a busy area without contrast? Does the decoration suit the energy? Does the page differ from the one before? Fix what fails with revise_book_design.
@@ -244,7 +244,7 @@ function lookOf(
const centre = area ? nearbyName(card, (area.south + area.north) / 2, (area.west + area.east) / 2) : null;
return { label: centre, area };
}
if (toolName === 'propose_hike' || toolName === 'propose_tour') return hikeLook(args);
if (toolName === 'propose_hike') return hikeLook(args);
if (toolName === 'search_place') {
const bias = recordOf(args.locationBias);
const area = pointArea(bias.lat, bias.lng, bias.radius, BIAS_RADIUS_M);
+1 -11
View File
@@ -93,20 +93,10 @@ function foundPlaceOutcome(part: ToolCallPart): string {
return `The user picked ${place} from the search results for ${day}; it waits for them to add or discard it.`;
}
/** What a taken proposal created, by the tool it went through; a hike for any other. */
const TAKEN_WORDS: Readonly<Record<string, string>> = {
create_city_walk: 'city walk',
'create_tour:bike': 'bike tour',
'create_tour:run': 'run',
create_paddle_tour: 'paddle tour',
create_ski_tour: 'ski tour',
};
/** What became of a proposal the user took from an answer to add it to the trip, as its stored part says. */
function takenProposalOutcome(part: ToolCallPart): string {
const name = quoted(part.args.name);
const via = part.args.via === 'create_tour' ? `create_tour:${String(part.args.activity)}` : String(part.args.via);
const what = TAKEN_WORDS[via] ?? 'hike';
const what = part.args.via === 'create_city_walk' ? 'city walk' : 'hike';
if (part.approval?.approved === true) {
return part.isError ? `The ${what} ${name} could not be added.` : `The ${what} ${name} was added to the trip.`;
}
@@ -1,132 +0,0 @@
import { recordOf, type Row } from './cards/card-values';
import { haversineMetres } from '@trek/shared';
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared/i18n';
/**
* "Plan this" on a paddle or ski plan an answer showed (plan_paddle_route,
* plan_ski_route): the create it holds, from the plan's own arguments and
* its answer read again whole. The legs of a plan must run on from one to
* the next to be one tour; its points are the first leg's start and every
* leg's end. A paddle tour's water is read from the plan (a river where any
* stretch is on running water, else a lake), its name from the waters it
* runs on, a ski tour's from its ski areas; where neither names one, the
* tour type's own word in the user's language.
*/
/** The plans a tour can be taken from, and the create each one is saved through. */
export const SPORT_CREATES = {
plan_paddle_route: 'create_paddle_tour',
plan_ski_route: 'create_ski_tour',
} as const;
export type SportPlanTool = keyof typeof SPORT_CREATES;
/** Whether a tool is one of the plans a tour can be taken from. */
export function isSportPlan(toolName: string): toolName is SportPlanTool {
return Object.hasOwn(SPORT_CREATES, toolName);
}
/** How near one leg's end must lie to the next leg's start for the two to be one tour, in metres. */
const JOINED_M = 5;
/** The longest name a tour is given from its waters or areas, well inside a tour's name. */
const NAME_CHARS = 120;
/** A point as the plan names it, or null. */
function pointOf(value: unknown): { lat: number; lng: number } | null {
const point = recordOf(value);
return typeof point.lat === 'number' && typeof point.lng === 'number' ? { lat: point.lat, lng: point.lng } : null;
}
/** The points of a plan's legs, the first leg's start then every leg's end; null where the legs do not join. */
export function waypointsOfLegs(legs: unknown): Array<{ lat: number; lng: number }> | null {
const rows = Array.isArray(legs) ? legs.map(recordOf) : [];
const first = pointOf(rows[0]?.from);
if (!first) return null;
const points = [first];
for (const [index, leg] of rows.entries()) {
const from = pointOf(leg.from);
const to = pointOf(leg.to);
const before = points[index];
if (!from || !to || !before || haversineMetres(before.lat, before.lng, from.lat, from.lng) > JOINED_M) return null;
points.push(to);
}
return points.length >= 2 ? points : null;
}
/** Each planned leg of an answer; null where one failed, which takes the whole plan out of reach. */
function plannedLegs(result: Row): Row[] | null {
const legs = Array.isArray(result.legs) ? result.legs.map(recordOf) : [];
return legs.length > 0 && legs.every((leg) => leg.status === 'ok') ? legs : null;
}
/** "Isar → Ammersee": the first and last names given, each once, clipped. */
function namedAlong(names: readonly unknown[]): string {
const kept = names.flatMap((name) => (typeof name === 'string' && name.trim() !== '' ? [name.trim()] : []));
const ends = [...new Set([kept[0], kept.at(-1)])].filter((name): name is string => !!name);
const name = ends.join(' → ');
return name.length > NAME_CHARS ? `${name.slice(0, NAME_CHARS - 1)}…` : name;
}
/** The water a paddle plan runs on: a river where any stretch is on running water, else a lake. */
function waterOf(legs: readonly Row[]): 'river' | 'lake' {
const kinds = legs.flatMap((leg) => (Array.isArray(leg.segments) ? leg.segments.map((s) => recordOf(s).kind) : []));
return kinds.some((kind) => kind === 'water' || kind === 'rapids' || kind === 'pass') ? 'river' : 'lake';
}
/**
* The create a taken paddle or ski plan holds: its points, the plan's own
* settings and a name; null where a leg failed or the legs do not join.
*/
export function sportTakeArgsOf(toolName: SportPlanTool, args: Row, result: Row, fallbackName: string): Row | null {
const legs = plannedLegs(result);
const waypoints = waypointsOfLegs(args.legs);
if (!legs || !waypoints) return null;
const via = SPORT_CREATES[toolName];
if (via === 'create_paddle_tour') {
const waters = legs.flatMap((leg) =>
Array.isArray(leg.segments) ? leg.segments.map((s) => recordOf(s).name) : [],
);
const take: Row = { via, name: namedAlong(waters) || fallbackName, waypoints, water_context: waterOf(legs) };
if (args.boat !== undefined) take.boat_type = args.boat;
if (args.lake_style !== undefined) take.lake_style = args.lake_style;
if (args.lock_policy !== undefined) take.lock_policy = args.lock_policy;
return take;
}
const areas = Array.isArray(result.area_names) ? result.area_names : [];
const take: Row = { via, name: namedAlong(areas) || fallbackName, waypoints, discipline: args.discipline };
if (args.technique !== undefined && args.technique !== null) take.technique = args.technique;
if (args.options !== undefined) take.options = args.options;
return take;
}
/** The locale key each plan's tour type is named by. */
const TYPE_WORD_KEYS: Readonly<Record<SportPlanTool, string>> = {
plan_paddle_route: 'tourTypes.kayak',
plan_ski_route: 'tourTypes.ski',
};
/** The words of one locale's table, or none when it cannot be read. */
async function wordsOf(language: string): Promise<Record<string, unknown>> {
try {
// A supported code only, so the path is one of the package's own locale entries.
const loaded: unknown = await import(`@trek/shared/i18n/${language}`);
const words = (loaded as { default?: unknown } | null)?.default;
return words !== null && typeof words === 'object' ? (words as Record<string, unknown>) : {};
} catch {
return {};
}
}
/** A plan's tour type in the user's language ("Paddeltour"), English where the language has no table. */
export async function tourTypeWord(locale: string, toolName: SportPlanTool): Promise<string> {
const language = locale.split('-')[0] ?? 'en';
const code = SUPPORTED_LANGUAGE_CODES.includes(locale)
? locale
: SUPPORTED_LANGUAGE_CODES.includes(language)
? language
: 'en';
const key = TYPE_WORD_KEYS[toolName];
const own = (await wordsOf(code))[key];
if (typeof own === 'string') return own;
const english = (await wordsOf('en'))[key];
return typeof english === 'string' ? english : 'Tour';
}
@@ -22,14 +22,13 @@ import { RateLimitService } from '../common/rate-limit.service';
import { UnitOfWork } from '../database/unit-of-work';
import { buildTripCard, categoriesFrom, todayIn, type TripCard } from './assistant-context';
import { partsOf, toThreadMessages } from './assistant-history';
import { isSportPlan, SPORT_CREATES, sportTakeArgsOf, tourTypeWord } from './assistant-proposal-sports';
import { AssistantStreamRegistry } from './assistant-stream.registry';
import { TAKE_PROPOSAL_TOOL, TURNS_PER_MINUTE } from './assistant-tools.constants';
import { AssistantToolsService, type ToolRun } from './assistant-tools.service';
import { AssistantCardContext } from './cards/card-context';
import { recordOf, type Row } from './cards/card-values';
import { argText } from './cards/tour/tour-card-values';
import { cityWalkCard, paddleCard, skiCard } from './cards/tour/tour-sport.cards';
import { cityWalkCard } from './cards/tour/tour-sport.cards';
import { describeToolCall } from './tool-call-preview.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
@@ -50,17 +49,8 @@ import { randomUUID } from 'node:crypto';
*/
const WALK_READ = 'propose_city_walk';
/**
* What a taken proposal reads: the trip's days and places, the category names
* its card writes with, a walk or a paddle or ski plan again.
*/
const TRIP_READS: ReadonlySet<string> = new Set([
'get_trip_summary',
'list_places',
'list_categories',
WALK_READ,
...Object.keys(SPORT_CREATES),
]);
/** What a taken proposal reads: the trip's days and places, the category names its card writes with, a walk again. */
const TRIP_READS: ReadonlySet<string> = new Set(['get_trip_summary', 'list_places', 'list_categories', WALK_READ]);
/** Message rows searched for the proposal; the history a turn sends the provider. */
const SEARCHED_ROWS = 40;
@@ -79,11 +69,9 @@ interface StoredProposal {
}
/** The proposing reads a card can be taken from, and the tool each one is created through. */
const CREATES: Readonly<Record<string, string>> = {
const CREATES: Readonly<Record<string, 'create_hike' | 'create_city_walk'>> = {
propose_hike: 'create_hike',
propose_tour: 'create_tour',
propose_city_walk: 'create_city_walk',
...SPORT_CREATES,
};
const PROPOSING: ReadonlySet<string> = new Set(Object.keys(CREATES));
@@ -115,26 +103,6 @@ function walkName(stations: readonly Row[], result: Row): string {
return name.length > WALK_NAME_CHARS ? `${name.slice(0, WALK_NAME_CHARS - 1)}…` : name;
}
/**
* A paddle or ski plan asked again with the arguments it was planned with:
* the stored answer is the model's shortened copy, and its legs' stretches
* name the water or the areas the tour is called after. The card is drawn
* again from the whole answer (the stored one when none can be drawn). Null
* when the plan cannot be read now.
*/
async function planAgain(
read: (name: string, args: Row) => Promise<ToolRun>,
proposal: StoredProposal,
trip: TripCard,
today: string,
): Promise<StoredProposal | null> {
const run = await read(proposal.toolName, proposal.args);
if (!run.ok) return null;
const input = { toolCallId: '', args: proposal.args, value: run.value, card: trip, today };
const drawn = proposal.toolName === 'plan_ski_route' ? skiCard(input) : paddleCard(input);
return { ...proposal, result: recordOf(run.value), card: drawn ?? proposal.card };
}
/**
* A walk asked again with the arguments it was proposed with: the stations to
* create from the tool's own answer, and the card drawn from that answer (the
@@ -165,7 +133,7 @@ export function pendingCardOf(card: TourRouteCard, args: Row): TourRouteCard {
return { ...card, source: 'pending', name: argText(args.name, 120) ?? card.name, label: null, schedule };
}
/** The fields of a propose_hike create_hike takes as they are, and those of a propose_tour create_tour takes. */
/** The fields of a propose_hike create_hike takes as they are. */
const HIKE_FIELDS = [
'name',
'waypoints',
@@ -175,42 +143,21 @@ const HIKE_FIELDS = [
'day_id',
'start_time',
] as const;
const TOUR_FIELDS = [
'name',
'activity',
'bike_profile',
'run_profile',
'run_pace_seconds_per_km',
'waypoints',
'round_trip',
'day_id',
'start_time',
] as const;
/**
* The change a taken proposal holds: create_hike (create_tour for a bike
* tour or a run) with the proposal's own arguments for a way that was
* planned, create_paddle_tour or create_ski_tour with a plan's points and
* settings, create_city_walk with the
* The change a taken proposal holds: create_hike with the proposal's own
* arguments for a hike whose way was planned, create_city_walk with the
* stations it answered for a walk that can be created. Null for a proposal
* nothing can be created from.
*/
export function takeArgsOf(proposal: StoredProposal, fallbackName = ''): Row | null {
export function takeArgsOf(proposal: StoredProposal): Row | null {
const via = CREATES[proposal.toolName];
if (via === 'create_hike' || via === 'create_tour') {
if (via === 'create_hike') {
if (!proposal.card.routed) return null;
// A run is saved with the runner's pace only, as the planner saves one.
if (via === 'create_tour' && proposal.args.activity === 'run' && proposal.args.run_pace_seconds_per_km == null) {
return null;
}
const args: Row = { via };
for (const key of via === 'create_hike' ? HIKE_FIELDS : TOUR_FIELDS) {
if (proposal.args[key] !== undefined) args[key] = proposal.args[key];
}
for (const key of HIKE_FIELDS) if (proposal.args[key] !== undefined) args[key] = proposal.args[key];
return args;
}
if (isSportPlan(proposal.toolName))
return sportTakeArgsOf(proposal.toolName, proposal.args, proposal.result, fallbackName);
const stations = Array.isArray(proposal.result.stations_for_create) ? proposal.result.stations_for_create : [];
if (proposal.result.can_create === false || stations.length < 2) return null;
return { via, name: walkName(stations.map(recordOf), proposal.result), stations };
@@ -280,8 +227,7 @@ export class AssistantProposalsService {
const id = randomUUID();
const today = todayIn(req.timeZone, new Date());
const { card, categories, proposal } = await this.tripOf(userId, tripId, stored, today);
const fallback = isSportPlan(stored.toolName) ? await tourTypeWord(req.locale, stored.toolName) : '';
const args = proposal ? takeArgsOf(proposal, fallback) : null;
const args = proposal ? takeArgsOf(proposal) : null;
if (!proposal || !args) throw aiRefusal('generic', 'Nothing can be created from this proposal: ask for it again');
const ctx = this.cardContext.contextOf(card, categories, req.locale, today);
// The proposal it came from, so the thread tells which card was taken, whatever way it creates.
@@ -348,24 +294,13 @@ export class AssistantProposalsService {
return {
card,
categories: new Map(categories.map((category): [number, string] => [category.id, category.name])),
proposal: await this.again(read, stored, card, today),
proposal: stored.toolName === WALK_READ ? await walkAgain(read, stored, card, today) : stored,
};
} finally {
await session.close();
}
}
/** The proposal as it is taken: a walk or a paddle or ski plan asked again, a hike or tour as it was stored. */
private async again(
read: (name: string, args: Row) => Promise<ToolRun>,
stored: StoredProposal,
card: TripCard,
today: string,
): Promise<StoredProposal | null> {
if (stored.toolName === WALK_READ) return await walkAgain(read, stored, card, today);
return isSportPlan(stored.toolName) ? await planAgain(read, stored, card, today) : stored;
}
/** The newest answer of the thread as the client shows it, with what became of its changes. */
private async answerOf(threadId: number): Promise<AssistantMessage> {
const rows = await this.messages.listLatest(threadId, THREAD_ROWS);
@@ -226,16 +226,14 @@ type ToolGroups = Readonly<Record<string, { about: string; controllers: readonly
const CORE_TOOL_GROUPS: ToolGroups = {
tours: {
about:
'Tours: hikes (also over several days in stages), city walks, bike tours (road, gravel, mountain bike), ' +
'runs (road and trail), rides, paddling and ski and winter tours; plan and save every one of them, change ' +
'and export them (hikes with their heights and SAC grades), search along a ' +
'Tours: hikes (also over several days in stages), city walks, bike tours, runs, rides, paddling and ski ' +
'routes; plan, create, change and export them (hikes with their heights and SAC grades), search along a ' +
'tour, check off a city walk under way, the recorded tracks and tour stats, their packing, tours in ' +
'collections, the tour library and imports from GPX files and Google Maps walks.',
controllers: [
'ToursMcp',
'CityWalkMcp',
'HikePlanMcp',
'TourPlanMcp',
'TourChangeMcp',
'TourChangeUndoMcp',
'TourLibraryMcp',
@@ -145,14 +145,8 @@ export const PREVIEW_BOOK_PAGE_TOOL = 'preview_book_page';
/** The model's tools a found place is added through: a new place for the day, or one the trip has already. */
const FOUND_PLACE_VIA: ReadonlySet<string> = new Set(['create_and_assign_place', 'assign_place_to_day']);
/** The model's tools a taken proposal is created through: a hike, a bike tour or a run, a city walk, a paddle or ski tour. */
const PROPOSAL_VIA: ReadonlySet<string> = new Set([
'create_hike',
'create_tour',
'create_city_walk',
'create_paddle_tour',
'create_ski_tour',
]);
/** The model's tools a taken proposal is created through: a hike, a city walk. */
const PROPOSAL_VIA: ReadonlySet<string> = new Set(['create_hike', 'create_city_walk']);
/**
* Changes the server applies itself: a booking from an attached file, a search
+1 -41
View File
@@ -6,7 +6,6 @@ import {
createHikeCard,
createHikeEffect,
loadCreateHike,
loadCreateTour,
loadUpdateTour,
updateTourCard,
type TourLoaded,
@@ -14,13 +13,6 @@ import {
import { TOUR_DESCRIBERS } from './tour-describe';
import { tourListCard } from './tour-lists.cards';
import { proposalCard, savedTourCard } from './tour-route.cards';
import {
createPaddleCard,
createSkiCard,
loadCreatePaddle,
loadCreateSki,
type SportLoaded,
} from './tour-sport-change.cards';
import { cityWalkCard, paddleCard, skiCard } from './tour-sport.cards';
import { DELETE_CREATED_TOUR, DELETE_IMPORTED_TOUR } from './tour-undo';
import { describeVisit, loadVisit, SET_VISIT_UNDO } from './tour-visit.cards';
@@ -55,34 +47,6 @@ const createHike: ToolCards<TourLoaded> = {
undo: DELETE_CREATED_TOUR,
};
/** create_tour: a bike tour or a run, drawn and taken back as a hike is, its effect on the day too. */
const createTour: ToolCards<TourLoaded> = {
write: { verbKey: 'addTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.create_tour,
load: loadCreateTour,
change: createHikeCard,
effect: createHikeEffect,
applied: appliedTourCard,
undo: DELETE_CREATED_TOUR,
};
/** A paddle or ski tour saved from its plan: the plan's card waiting, the receipt opening the tour, its delete the way back. */
function sportCreate(
describe: ToolCards<SportLoaded>['describe'],
load: NonNullable<ToolCards<SportLoaded>['load']>,
change: NonNullable<ToolCards<SportLoaded>['change']>,
): ToolCards<SportLoaded> {
return {
write: { verbKey: 'addTour', entity: 'tour' },
describe,
load,
change,
effect: createHikeEffect,
applied: appliedTourCard,
undo: DELETE_CREATED_TOUR,
};
}
const updateTour: ToolCards<TourLoaded> = {
write: { verbKey: 'updateTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.update_tour,
@@ -96,7 +60,6 @@ export const TOUR_CARDS: DomainCards = {
id: 'tour',
tools: {
propose_hike: { argPolicy: WITH_GEOMETRY, answer: proposalCard },
propose_tour: { argPolicy: WITH_GEOMETRY, answer: proposalCard },
propose_city_walk: { argPolicy: WITH_GEOMETRY, answer: cityWalkCard },
plan_paddle_route: { argPolicy: WITH_GEOMETRY, answer: paddleCard },
plan_ski_route: { argPolicy: WITH_GEOMETRY, answer: skiCard },
@@ -104,9 +67,6 @@ export const TOUR_CARDS: DomainCards = {
list_tours: { answer: tourListCard },
find_tours_for_day: { answer: tourListCard },
create_hike: createHike,
create_tour: createTour,
create_paddle_tour: sportCreate(TOUR_DESCRIBERS.create_paddle_tour, loadCreatePaddle, createPaddleCard),
create_ski_tour: sportCreate(TOUR_DESCRIBERS.create_ski_tour, loadCreateSki, createSkiCard),
create_city_walk: {
write: { verbKey: 'addTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.create_city_walk,
@@ -134,7 +94,7 @@ export const TOUR_CARDS: DomainCards = {
},
},
prompt: [
"Every tour type can be planned and saved. Pick the tool by what the user does: a hike (also a trek over several days) propose_hike then create_hike; a bike tour or a run propose_tour (activity bike or run) then create_tour; a city walk propose_city_walk then create_city_walk; a paddle tour plan_paddle_route then create_paddle_tour (it needs water_context river or lake); a ski or winter tour (alpine, snowboard, cross-country, ski touring, snowshoe, winter hike) plan_ski_route then create_ski_tour with the same points, discipline and options, passing the plan's points in order (the from of the first leg and the to of every leg). Profiles: a bike tour is touring (city or trekking bike, the default), road (asphalt only), gravel (gravel and farm tracks) or mtb (rough tracks and trails); a run is running (mostly paved, the default) or trail; ask when it is not clear. A run needs the runner's pace (run_pace_seconds_per_km, 330 for 5:30 min/km): ask for it, never guess one. A hike: propose_hike with a name, and day_id and start_time when known; to compare ways call it up to three times in one step, each with a variant_label and along false, the way you recommend first; create_hike only once the user chose a way. A hike over several days is one hike: stage_end on each stop a day ends at (no day_id), then each stage on its day with assign_place_to_day and tourStageKey.",
'Hikes: call propose_hike with a name, and day_id and start_time when known; to compare ways, call it up to three times in one step, each with a variant_label and along false, the way you recommend first (the card shows the first one chosen). Call create_hike only once the user chose a way. A hike over several days is one hike: stage_end on each stop a day ends at (no day_id), then each stage on its day with assign_place_to_day and tourStageKey.',
'The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters. Once a tour is planned, offer what goes with it: places along it (search_tour_pois), what to pack (get_tour_packing, link_tour_packing), the GPX file (export_tour_gpx).',
],
};
@@ -18,7 +18,7 @@ import {
straightMetres,
whole,
} from './tour-card-values';
import { hikeCard, landSportOf } from './tour-route.cards';
import { hikeCard } from './tour-route.cards';
import {
hikingPreferencesOf,
TOUR_ROUTE_LIMITS,
@@ -118,12 +118,8 @@ function heldProblem(card: TourRouteCard | null): TourRouteCard['problem'] {
return refuses && card?.problem ? card.problem : { why: 'not_planned', position: null, snappedM: null };
}
/** The points of a held tour joined straight, as stops along the straight line. */
export function straightCard(
points: readonly Row[],
activity: TourRouteCard['activity'],
fields: Partial<TourRouteCard>,
): TourRouteCard | null {
/** The points of a held hike or walk joined straight, as stops along the straight line. */
function straightCard(points: readonly Row[], activity: 'hike' | 'walk', fields: Partial<TourRouteCard>) {
const stops = points.flatMap((point) => {
const at = latLngOf(point);
return at ? [{ ...at, point }] : [];
@@ -159,45 +155,19 @@ function hikePoints(args: ToolArgs): Row[] {
return args.round_trip === true && points[0] ? [...points, points[0]] : points;
}
/**
* create_hike and create_tour: the planned way as the proposal drew it, else
* the points straight, a hike's under its limit, a bike tour's or a run's
* under its profile.
*/
/** create_hike: the planned way as the proposal drew it, else the points straight. */
export function createHikeCard(ctx: PreviewContext, args: ToolArgs, loaded: TourLoaded | null): TourRouteCard | null {
const name = argText(args.name, 120);
const planned = loaded ? hikeCard(loaded.plan, args, ctx.card, 'pending') : null;
if (planned?.routed) return fitted({ ...planned, name: name ?? planned.name, label: null });
const sport = landSportOf({}, args);
const own = sport ?? { limit: sacStepOf(args.max_hiking_difficulty) ?? TOURS_MAX_HIKING_DIFFICULTY };
const straight = straightCard(hikePoints(args), sport?.activity ?? 'hike', {
const straight = straightCard(hikePoints(args), 'hike', {
name,
...own,
limit: sacStepOf(args.max_hiking_difficulty) ?? TOURS_MAX_HIKING_DIFFICULTY,
problem: heldProblem(planned),
});
return straight ? fitted(straight) : null;
}
/** The fields propose_tour takes of a create_tour, as the planner's memo keys its way. */
const TOUR_PLAN_FIELDS = [
'name',
'activity',
'bike_profile',
'run_profile',
'run_pace_seconds_per_km',
'waypoints',
'round_trip',
'day_id',
'start_time',
] as const;
/** create_tour: plans the way through the points as propose_tour did (the planner's memo answers a recent one). */
export async function loadCreateTour(args: ToolArgs, read: CardReader): Promise<TourLoaded> {
const plan: ToolArgs = { along: false };
for (const key of TOUR_PLAN_FIELDS) if (args[key] !== undefined) plan[key] = args[key];
return { plan: await read('propose_tour', plan), saved: null };
}
/** create_city_walk: the stations joined straight; the way on foot is planned when the walk is saved. */
export function createCityWalkCard(_ctx: PreviewContext, args: ToolArgs): TourRouteCard | null {
const straight = straightCard(rowsIn(args.stations), 'walk', { name: argText(args.name, 120) });
@@ -5,8 +5,8 @@ import { TOURS_MAX_HIKING_DIFFICULTY } from '@trek/shared';
/**
* The change cards of the tours an assistant creates and changes: a hike, a
* bike tour, a run, a paddle or ski tour, a city walk, a change to a saved
* hike, tours taken in from a GPX file. The route reads as the names of its points in order, a hike's limit as its SAC
* city walk, a change to a saved hike, tours taken in from a GPX file. The
* route reads as the names of its points in order, a hike's limit as its SAC
* grade (T1 to T6), a planned time in hours and minutes.
*/
@@ -58,17 +58,6 @@ function gpxName(gpx: unknown): string {
return close < 0 ? '' : text(gpx.slice(open + '<name>'.length, close));
}
/** A tour of another type than a hike created from its plan: its points in order, its day, its planned time and words. */
function describeSportCreate(ctx: PreviewContext, args: ToolArgs): Described {
const day = onDay(ctx, id(args, 'day_id'), args.start_time);
const rows = new Rows()
.add('route', routeOf(ctx, args.waypoints, args.round_trip))
.add('day', day)
.add('time', hours(args.planned_duration_minutes))
.add('description', text(args.description));
return { subject: text(args.name), rows, dayId: day ? id(args, 'day_id') : null, placeIds: [] };
}
export const TOUR_DESCRIBERS = {
create_hike: (ctx, args) => {
const day = onDay(ctx, id(args, 'day_id'), args.start_time);
@@ -80,10 +69,6 @@ export const TOUR_DESCRIBERS = {
.add('description', text(args.description));
return { subject: text(args.name), rows, dayId: day ? id(args, 'day_id') : null, placeIds: [] };
},
// A bike tour, a run, a paddle or ski tour: its points and day; its profile and figures are on the route card below.
create_tour: describeSportCreate,
create_paddle_tour: describeSportCreate,
create_ski_tour: describeSportCreate,
create_city_walk: (ctx, args) => {
const rows = new Rows().add('route', routeOf(ctx, args.stations, false)).add('description', text(args.description));
return { subject: text(args.name), rows, dayId: null, placeIds: linkedPlaceIds(ctx, args.stations) };
@@ -28,12 +28,10 @@ import {
type RoutePoint,
} from './tour-card-values';
import {
BIKE_PROFILES,
cumulativeMetres,
HIKE_UNROUTED_REASONS,
legMinutes,
nearestAlongM,
RUN_PROFILES,
TOUR_ROUTE_LIMITS,
TOUR_WARNING_KINDS,
tourTypeKeySchema,
@@ -44,8 +42,7 @@ import {
} from '@trek/shared';
/**
* The cards of a hike, a bike tour or a run the assistant proposed
* (propose_hike, propose_tour) and of a saved
* The cards of a hike the assistant proposed (propose_hike) and of a saved
* tour it read (get_tour_plan), built from the tool's raw answer. A proposal
* draws its line only when the tool added it for drawing (the assistant
* always asks), so without `line_geometry` there is no card. A way that was
@@ -59,35 +56,6 @@ const SAME_SUMMIT_M = 150;
const LOOP_END_M = 60;
const UNROUTED: ReadonlySet<string> = new Set(HIKE_UNROUTED_REASONS);
const BIKE: ReadonlySet<string> = new Set(BIKE_PROFILES);
const RUN: ReadonlySet<string> = new Set(RUN_PROFILES);
/** A planned way's activity on land beyond a hike: a bike tour or a run, with its profile and a run's pace. */
type RoutingProfile = NonNullable<TourRouteCard['routingProfile']>;
type LandSport = Pick<TourRouteCard, 'activity' | 'routingProfile' | 'paceSecondsPerKm'>;
/**
* The bike tour or run a proposal (propose_tour) or its arguments name, with
* its profile (the planner's own where none is named) and a run's pace; null
* for a hike, whose card names none of them.
*/
export function landSportOf(plan: Row, args: Row): LandSport | null {
const activity = plan.activity ?? args.activity;
if (activity === 'bike') {
const profile = plan.bike_profile ?? args.bike_profile;
return {
activity,
routingProfile: typeof profile === 'string' && BIKE.has(profile) ? (profile as RoutingProfile) : 'touring',
};
}
if (activity !== 'run') return null;
const profile = plan.run_profile ?? args.run_profile;
return {
activity,
routingProfile: typeof profile === 'string' && RUN.has(profile) ? (profile as RoutingProfile) : 'running',
paceSecondsPerKm: whole(plan.run_pace_seconds_per_km ?? args.run_pace_seconds_per_km, 60, 3_600),
};
}
const WARNINGS: ReadonlySet<string> = new Set(TOUR_WARNING_KINDS);
/** Graded stretches merged where the step repeats, at most the card's share, the hardest kept. */
@@ -229,11 +197,9 @@ function unroutedHike(value: Row, args: Row, source: TourRouteCard['source']): T
const unreachable = recordOf(value.unreachable);
const why = typeof route.why === 'string' && UNROUTED.has(route.why) ? route.why : 'not_planned';
const along = straightMetres(points);
const sport = landSportOf(value, args);
return routeCard({
source,
activity: 'hike',
...sport,
name: clipped(value.name ?? args.name, 120),
label: clipped(value.variant_label ?? args.variant_label, 40),
limit: sacStepOf(value.max_hiking_difficulty ?? args.max_hiking_difficulty),
@@ -269,16 +235,11 @@ export function hikeCard(
const sac = plan.sac === null || plan.sac === undefined ? null : recordOf(plan.sac);
const start = clockOf(plan.start_time);
const own = waypointStops(rowsIn(plan.waypoints), start);
const basis =
plan.duration_basis === 'din33466' || plan.duration_basis === 'router' || plan.duration_basis === 'pace'
? plan.duration_basis
: null;
const basis = plan.duration_basis === 'din33466' || plan.duration_basis === 'router' ? plan.duration_basis : null;
const unknown = finite(sac?.unknown_share);
const sport = landSportOf(plan, args);
return routeCard({
source,
activity: 'hike',
...sport,
name: clipped(plan.name, 120),
label: clipped(plan.variant_label, 40),
distanceM: metresOf(plan.distance_m),
@@ -300,7 +261,7 @@ export function hikeCard(
});
}
/** propose_hike and propose_tour: the proposal as a card, a hike's or a bike tour's or a run's. */
/** propose_hike: the proposal as a card. */
export function proposalCard({ value, args, card }: AnswerInput): TourRouteCard | null {
const proposal = hikeCard(value, args, card, 'proposal');
return proposal ? fitted(proposal) : null;
@@ -386,13 +347,6 @@ export function savedTourCard({ value }: AnswerInput): TourRouteCard | null {
line,
stops: sortedStops(stops),
stages: whole(tour.stage_count, 2, 60),
...savedSportOf(activity.data, tour),
}),
);
}
/** A saved bike tour's or run's profile and a run's pace, as its card names them; nothing for any other type. */
function savedSportOf(activity: TourRouteCard['activity'], tour: Row): Partial<TourRouteCard> {
if (activity !== 'bike' && activity !== 'run') return {};
return landSportOf({ activity, ...tour }, {}) ?? {};
}
@@ -1,78 +0,0 @@
import type { CardReader, PreviewContext, ToolArgs } from '../card-types';
import { recordOf, type Row } from '../card-values';
import { argText, fitted, latLngOf, rowsIn } from './tour-card-values';
import { straightCard } from './tour-change.cards';
import { paddleCard, skiCard } from './tour-sport.cards';
import type { TourRouteCard } from '@trek/shared';
/**
* The change cards of a paddle or ski tour the assistant saves from its plan
* (create_paddle_tour, create_ski_tour): the legs between its points planned
* again as the plan planned them, drawn as the plan's card was, waiting, under
* the name the tour is saved with. Where the plan cannot be read now, the
* points are drawn straight and the way is planned when the change is applied.
*/
/** What a held paddle or ski tour looked up: the plan of its legs, null where it could not be read. */
export interface SportLoaded {
plan: unknown;
}
/** The legs a tour's points make, each from one to the next, as the planning tools take them. */
export function legArgsOf(
args: ToolArgs,
): Array<{ from: { lat: number; lng: number }; to: { lat: number; lng: number } }> {
const points = rowsIn(args.waypoints).flatMap((point) => latLngOf(point) ?? []);
return points.slice(1).map((to, index) => ({ from: points[index] ?? to, to }));
}
/** The fields of a create a plan was asked with besides its legs, as the planning tool names them. */
function planArgs(args: ToolArgs, fields: Readonly<Record<string, string>>): ToolArgs {
const plan: ToolArgs = { legs: legArgsOf(args) };
for (const [from, to] of Object.entries(fields)) if (args[from] !== undefined) plan[to] = args[from];
return plan;
}
/** create_paddle_tour: its legs planned on the water as plan_paddle_route planned them. */
export async function loadCreatePaddle(args: ToolArgs, read: CardReader): Promise<SportLoaded | null> {
const plan = planArgs(args, { boat_type: 'boat', lake_style: 'lake_style', lock_policy: 'lock_policy' });
if (rowsIn(plan.legs).length === 0) return null;
return { plan: await read('plan_paddle_route', plan) };
}
/** create_ski_tour: its legs planned on the piste data as plan_ski_route planned them. */
export async function loadCreateSki(args: ToolArgs, read: CardReader): Promise<SportLoaded | null> {
const plan = planArgs(args, { discipline: 'discipline', technique: 'technique', options: 'options' });
if (rowsIn(plan.legs).length === 0) return null;
return { plan: await read('plan_ski_route', plan) };
}
/** A plan's card, waiting under the name the tour is saved with; else the points straight. */
function heldCard(
ctx: PreviewContext,
args: ToolArgs,
loaded: SportLoaded | null,
activity: 'kayak' | 'ski',
): TourRouteCard | null {
const name = argText(args.name, 120);
const input = { toolCallId: '', args: { legs: legArgsOf(args) } as Row, card: ctx.card, today: ctx.today };
const value = loaded ? recordOf(loaded.plan) : null;
const drawn = value ? (activity === 'kayak' ? paddleCard : skiCard)({ ...input, value }) : null;
if (drawn && drawn.warnings.every((warning) => warning.kind !== 'legs_failed')) {
return fitted({ ...drawn, source: 'pending', name });
}
const straight = straightCard(rowsIn(args.waypoints), activity, { name });
return straight ? fitted(straight) : null;
}
export function createPaddleCard(
ctx: PreviewContext,
args: ToolArgs,
loaded: SportLoaded | null,
): TourRouteCard | null {
return heldCard(ctx, args, loaded, 'kayak');
}
export function createSkiCard(ctx: PreviewContext, args: ToolArgs, loaded: SportLoaded | null): TourRouteCard | null {
return heldCard(ctx, args, loaded, 'ski');
}
@@ -101,16 +101,14 @@ export interface RouteOptions {
*/
footOnly?: boolean;
/**
* A tour's own costing, sent to Valhalla as the planner in the browser
* sends it: on foot a hike's (its hiking limit and preferences, as
* `hikingCosting` builds them) or a run's (`runCosting`), by bike the
* bicycle costing of its profile (`bikeCosting`). Only Valhalla knows these:
* OSRM's foot profile takes a T5 ridge as readily as a forest road, and its
* bike profile knows no road bike from a mountain bike. So a way asked with
* one is asked of Valhalla alone and fails rather than fall back to OSRM,
* with a `RouteRefusal` of `no_valhalla` where there is no Valhalla to ask.
* A hike's own pedestrian costing (its hiking limit and preferences, as
* `hikingCosting` builds them), sent to Valhalla as the planner in the
* browser sends it. Only Valhalla knows a hiking limit: OSRM's foot profile
* takes a T5 ridge as readily as a forest road. So a walk asked with one is
* asked of Valhalla alone and fails rather than fall back to OSRM, with a
* `RouteRefusal` of `no_valhalla` where there is no Valhalla to ask.
*/
costingOptions?: Readonly<Record<string, number | string>>;
costingOptions?: Readonly<Record<string, number>>;
}
export type { TracedWay };
@@ -306,8 +304,7 @@ export class RoadtripRouterService {
): Promise<RoadtripRoute> {
const settings = await this.settings.getUserSettings(userId);
const footOnly = options.footOnly === true && profile === 'walking';
// A tour's own costing goes with a way on foot or by bike; a drive has its avoidances instead.
const hiking = profile === 'walking' || profile === 'cycling' ? options.costingOptions : undefined;
const hiking = profile === 'walking' ? options.costingOptions : undefined;
const key = JSON.stringify([
userId,
tripId,
@@ -351,11 +348,8 @@ export class RoadtripRouterService {
const { base, valhalla } = routersOf(settings);
let weighted = false;
if (hiking) {
const cycling = mode === 'cycling';
if (!valhalla) {
throw new RouteRefusal('no_valhalla', `No Valhalla is set to plan a ${cycling ? 'bike tour' : 'hike'} with`);
}
const legs = await this.valhallaLegs(valhalla, points, cycling ? 'bicycle' : 'pedestrian', { ...hiking });
if (!valhalla) throw new RouteRefusal('no_valhalla', 'No Valhalla is set to plan a hike with');
const legs = await this.valhallaLegs(valhalla, points, 'pedestrian', { ...hiking });
({ line, distance, duration, parts, snapped } = routeOfLegs(points, legs));
weighted = true;
} else if (footOnly && valhalla) {
@@ -479,8 +473,8 @@ export class RoadtripRouterService {
private async valhallaLegs(
valhalla: string,
points: Point[],
costing: 'auto' | 'pedestrian' | 'bicycle',
options: Record<string, number | string>,
costing: 'auto' | 'pedestrian',
options: Record<string, number>,
offBoatLines = false,
): Promise<ValhallaLegs> {
const legs: ValhallaLegs = [];
@@ -38,7 +38,5 @@ function routerRunner() {
TrekPlacesWinterService,
{ provide: SKI_ROUTER_RUNNER, useFactory: routerRunner },
],
// A ski tour the assistant planned is planned again when it is saved (ToursModule).
exports: [SkiRoutingService],
})
export class SkiRoutingModule {}
+4 -22
View File
@@ -321,19 +321,6 @@ export interface HikeProposalContext {
along: HikeAlongPlace[] | null;
/** Null where none was asked or it could not be had. */
weather: WeatherResult | null;
/**
* A bike tour's or a run's own time on the move, in minutes, which stands
* for the plan's and is shared out over the legs by their length: a ride's
* is the router's for its profile, a run's its distance at the pace (null
* without one). A hike leaves it out and keeps the signposts' rule.
*/
movingMinutes?: number | null;
}
/** The minutes from the start to each point, the whole time shared out by how far along the way each lies. */
function minutesByLength(positions: readonly number[], total: number): number[] {
const length = positions.at(-1) ?? 0;
return positions.map((at) => (length > 0 ? Math.round((total * at) / length) : 0));
}
/**
@@ -354,21 +341,16 @@ export function hikeProposalOf(
const routed = plan.kind === 'routed' ? plan : null;
const positions = routed ? waypointPositionsOf(planned, points) : null;
const heights = routed?.has_heights && line ? heightRangeOf(line) : null;
const own = context.movingMinutes !== undefined;
const total = routed ? (own ? (context.movingMinutes ?? null) : routed.duration_minutes) : null;
let minutes: number[] | null = null;
if (line && positions && total !== null) {
minutes = own ? minutesByLength(positions, total) : waypointMinutesOf(line, positions, total);
}
const minutes = routed && line && positions ? waypointMinutesOf(line, positions, routed.duration_minutes) : null;
const arrivals = arrivalsOf(request.start_time ?? null, minutes ?? points.map(() => null));
// One day's sun and hours say nothing about a hike over several days.
const day =
total !== null && context.date && hikeStagesOf(points) === 1
routed && context.date && hikeStagesOf(points) === 1
? hikeScheduleOf({
date: context.date,
dayId: context.dayId,
start: request.start_time ?? null,
minutes: total,
minutes: routed.duration_minutes,
weather: context.weather,
})
: null;
@@ -380,7 +362,7 @@ export function hikeProposalOf(
distance_m: routed?.distance_m ?? null,
ascent_m: routed?.ascent_m ?? null,
descent_m: routed?.descent_m ?? null,
duration_minutes: total,
duration_minutes: routed?.duration_minutes ?? null,
duration_basis: routed?.duration_basis ?? null,
heights_known: heights !== null,
min_ele_m: heights?.min ?? null,
+17 -102
View File
@@ -17,20 +17,8 @@ import {
weatherPointOf,
type KeptWay,
} from './hike-plan.helpers';
import { HikeRoutingService, type PlannedActivity, type PlannedHike } from './hike-routing.service';
import { HikeRoutingService, type PlannedHike } from './hike-routing.service';
import { noTrackFound } from './tour-file-import.helpers';
import {
ALONG_CATEGORIES,
asHikeCreate,
asHikeProposal,
checkedRequest,
movingMinutesOf,
plannedActivityOf,
refusesSave,
tourCreateCommandOf,
tourProposalOf,
unplannedTourMessage,
} from './tour-plan.helpers';
import { TourPoiSearchService } from './tour-poi-search.service';
import { tourRevisionConflict } from './tour-revision';
import { thinnedLine } from './tours.helpers';
@@ -38,14 +26,13 @@ import { ToursService, type ImportGpxAsTourResult, type TourCreateCommand } from
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import {
HIKE_ALONG_CATEGORIES,
HIKE_ALONG_CORRIDOR_M,
HIKE_PLAN_MAX_WAYPOINTS,
hikeStageIssues,
hikingPreferencesOf,
TOUR_POI_MAX_LINE_POINTS,
tourGpxImportFieldsSchema,
tourPlanCreateRequestSchema,
tourProposeRequestSchema,
TOURS_MAX_HIKING_DIFFICULTY,
type GeometryPoint,
type HikeAlongPlace,
@@ -58,9 +45,6 @@ import {
type TourCreateResponse,
type TourDetailResponse,
type TourGpxImportRequest,
type TourPlanCreateRequest,
type TourProposal,
type TourProposeRequest,
type HikeRouteOutcome,
type TourUpdateRequest,
type TourWaypoint,
@@ -173,7 +157,6 @@ export class HikePlanService {
limit: number | undefined,
preferences: HikingPreferences | null | undefined,
grades: boolean,
activity: PlannedActivity | null = null,
): Promise<PlannedHike> {
// The router takes the points only: where a stage ends is the hike's, not the way's.
const waypoints = points.map(({ lat, lng, name, poi_category }) => ({
@@ -186,8 +169,7 @@ export class HikePlanService {
userId,
tripId,
{ waypoints, max_hiking_difficulty: limit, hiking_preferences: preferences },
// A hike asks as it always has; a ride or a run names its activity and reads no grades.
activity ? { grades: false, activity } : { grades },
{ grades },
);
}
@@ -199,41 +181,17 @@ export class HikePlanService {
* best effort: without them the proposal still answers.
*/
async propose(userId: number, tripId: number, request: HikeProposeRequest): Promise<HikeProposal> {
return await this.proposed(userId, tripId, request, null, null);
}
/**
* A bike tour or a run proposed the way a hike is: the same points, day,
* places along the way and weather, planned for its profile and timed by
* its own time (a ride the router's, a run its distance at the pace).
*/
async proposeTour(userId: number, tripId: number, request: TourProposeRequest): Promise<TourProposal> {
const asked = checkedRequest<TourProposeRequest>(tourProposeRequestSchema, request);
const activity = plannedActivityOf(asked);
const base = await this.proposed(userId, tripId, asHikeProposal(asked), activity, asked.run_pace_seconds_per_km);
return tourProposalOf(base, asked, activity);
}
/** The proposal of a hike, or with `activity` of a ride or a run, timed by its own time. */
private async proposed(
userId: number,
tripId: number,
request: HikeProposeRequest,
activity: PlannedActivity | null,
pace: number | null | undefined,
): Promise<HikeProposal> {
refuseBadStages(request.waypoints, request.round_trip);
const day = await this.dayOf(tripId, request.day_id, request.date);
const points = hikePointsOf(request.waypoints, request.round_trip);
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
const planned = await this.plan(userId, tripId, points, limit, preferences, activity === null, activity);
const planned = await this.plan(userId, tripId, points, limit, preferences, true);
const line = planned.plan.kind === 'routed' ? planned.line : null;
const [along, weather] = await Promise.all([
line && request.along !== false ? this.alongOf(line, activity?.activity ?? 'hike') : null,
line && request.along !== false ? this.alongOf(line) : null,
line && day.date ? this.weatherOf(weatherPointOf(points, planned), day.date) : null,
]);
const timed = activity ? { movingMinutes: movingMinutesOf(activity, pace, planned) } : {};
return hikeProposalOf(request, points, planned, { ...day, along, weather, ...timed });
return hikeProposalOf(request, points, planned, { ...day, along, weather });
}
/** The trip's day a hike is planned on and its date, or the date given without a day. */
@@ -248,24 +206,17 @@ export class HikePlanService {
return { dayId: day.id, date: day.date };
}
/**
* The places near the way the activity stops for (summits, huts, water and
* viewpoints on a hike), or null where the search could not be made.
*/
private async alongOf(
line: readonly GeometryPoint[],
type: keyof typeof ALONG_CATEGORIES,
): Promise<HikeAlongPlace[] | null> {
const categories = ALONG_CATEGORIES[type];
/** Summits, huts, water and viewpoints near the way, or null where the search could not be made. */
private async alongOf(line: readonly GeometryPoint[]): Promise<HikeAlongPlace[] | null> {
try {
const found = await this.poiSearch.search({
tour_type: type,
categories: [...categories],
tour_type: 'hike',
categories: [...HIKE_ALONG_CATEGORIES],
corridor_m: HIKE_ALONG_CORRIDOR_M,
line: thinnedLine(line, TOUR_POI_MAX_LINE_POINTS).map(([lat, lng]): [number, number] => [lat, lng]),
});
// Not one kind could be searched: nothing found would read as nothing there.
if (found.unavailable.length === categories.length) return null;
if (found.unavailable.length === HIKE_ALONG_CATEGORIES.length) return null;
return alongPlacesOf(found.pois, line);
} catch (err: unknown) {
logWarn(`[Hike] no places along the way: ${reasonOf(err)}`);
@@ -307,53 +258,17 @@ export class HikePlanService {
throw notRouted(unplannedCreateMessage(way.route));
}
const command = hikeCreateCommandOf(request, points, way, planned.plan.max_hiking_difficulty);
return await this.saveOnDay(tripId, command, day?.dayId ?? null, request.start_time, way.route);
}
/**
* A bike tour or a run created the way a hike is (create): planned again
* through its points for its profile, saved with its line and heights, its
* time (a ride the router's, a run its distance at the pace), its profile
* and pace, put on its day in the same write. A router that did not answer
* saves its points only; any other reason refuses it, and nothing is saved.
*/
async createTour(userId: number, tripId: number, request: TourPlanCreateRequest): Promise<HikeCreated> {
const asked = checkedRequest<TourPlanCreateRequest>(tourPlanCreateRequestSchema, request);
const activity = plannedActivityOf(asked);
const hike = asHikeCreate(asked);
const day = hike.day_id === undefined ? null : await this.dayOf(tripId, hike.day_id, undefined);
const points = hikePointsOf(hike.waypoints, hike.round_trip);
const planned = await this.plan(userId, tripId, points, undefined, undefined, false, activity);
const way = keptWayOf(planned);
if (refusesSave(way.route)) throw notRouted(unplannedTourMessage(way.route, activity.activity));
const minutes = movingMinutesOf(activity, asked.run_pace_seconds_per_km, planned);
const command = tourCreateCommandOf(asked, points, way, activity, minutes);
return await this.saveOnDay(tripId, command, day?.dayId ?? null, hike.start_time, way.route);
}
/**
* A planned tour saved through the creation the planner's save uses. With
* a day it is put on that day in the same write, its start kept as the
* visit's, and the trip is told of the tour and the visit once both are
* saved, as the planner's save and its drop onto the day tell it.
*/
async saveOnDay(
tripId: number,
command: TourCreateCommand,
dayId: number | null,
startTime: string | undefined,
route: HikeRouteOutcome,
): Promise<HikeCreated> {
if (dayId === null) {
if (day?.dayId == null) {
const created = await this.tours.createTour(String(tripId), command);
return { ...created, route, assignment: null };
return { ...created, route: way.route, assignment: null };
}
const dayId = day.dayId;
const written = await this.uow.transactional(async () => {
const placeId = await this.tours.insertTour(tripId, command);
const added = await this.assignments.createAssignment(dayId, placeId, null);
if (!added) throw new Error('Created visit could not be loaded');
if (!startTime) return { placeId, assignment: added, reordered: null, vias: null };
const timed = await this.assignments.updateTime(added.id, startTime, null);
if (!request.start_time) return { placeId, assignment: added, reordered: null, vias: null };
const timed = await this.assignments.updateTime(added.id, request.start_time, null);
return { placeId, ...timed, assignment: timed.assignment ?? added };
});
const trip = String(tripId);
@@ -363,7 +278,7 @@ export class HikePlanService {
if (written.reordered) this.assignments.broadcast(trip, 'assignment:reordered', written.reordered, undefined);
if (written.vias) this.assignments.broadcast(trip, 'roadtripVia:changed', written.vias, undefined);
await this.assignments.reconcile(tripId);
return { ...created, route, assignment: { id: written.assignment.id, day_id: dayId } };
return { ...created, route: way.route, assignment: { id: written.assignment.id, day_id: dayId } };
}
/**
+10 -53
View File
@@ -6,7 +6,6 @@ import { TourTrailGradeService } from './tour-trail-grade.service';
import { thinnedLine } from './tours.helpers';
import { Injectable } from '@nestjs/common';
import {
bikeCosting,
computeTourMetrics,
HIKE_MAX_LINE_POINTS,
HIKE_PLAN_LINE_POINTS,
@@ -15,18 +14,14 @@ import {
hikingCosting,
hikingPreferencesOf,
hikingTimeMinutes,
runCosting,
sacStepOf,
TOUR_MAX_SNAP_M,
TOUR_TRAIL_GRADE_MAX_WAYS,
TOURS_MAX_HIKING_DIFFICULTY,
type BikeProfile,
type GeometryPoint,
type HikePlan,
type HikePlanRequest,
type HikingPreferences,
type HikeTrailGrades,
type RunProfile,
type SacStep,
type UnroutedHikePlan,
} from '@trek/shared';
@@ -84,26 +79,14 @@ interface KeptPlan {
planned: PlannedHike;
}
/**
* A tour planned through the same way as a hike but for another activity: a
* bike tour by bike for its profile, a run on foot for its profile. Neither
* takes a hiking limit, preferences or SAC grades.
*/
export type PlannedActivity = { activity: 'bike'; profile: BikeProfile } | { activity: 'run'; profile: RunProfile };
/**
* The request as the memo keys it: who asked in which trip, the points to
* about a metre, the limit, the preferences, and for a bike tour or a run its
* activity and profile.
*/
function memoKeyOf(userId: number, tripId: number, request: HikePlanRequest, activity: PlannedActivity | null): string {
/** The request as the memo keys it: who asked in which trip, the points to about a metre, the limit, the preferences. */
function memoKeyOf(userId: number, tripId: number, request: HikePlanRequest): string {
const points = request.waypoints.map(({ lat, lng }) => [
Number(lat.toFixed(PLAN_MEMO_DECIMALS)),
Number(lng.toFixed(PLAN_MEMO_DECIMALS)),
]);
const limit = request.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY;
const key = [userId, tripId, points, limit, hikingPreferencesOf(request.hiking_preferences)];
return JSON.stringify(activity ? [...key, activity.activity, activity.profile] : key);
return JSON.stringify([userId, tripId, points, limit, hikingPreferencesOf(request.hiking_preferences)]);
}
/** What a plan asks for besides the way and its heights. */
@@ -113,27 +96,6 @@ export interface HikePlanOptions {
* trail grades): a proposal shows them, a hike being saved keeps none.
*/
grades: boolean;
/**
* A bike tour or a run, planned with its own costing and timed by the
* router; a hike when left out. Its limit and preferences are a hike's and
* go unread.
*/
activity?: PlannedActivity;
}
/**
* How a way is asked of the router: on foot with a hike's limit and
* preferences, by bike with its profile's bicycle costing, on foot with a
* run's pedestrian costing.
*/
function costingOf(
activity: PlannedActivity | null,
limit: number,
preferences: HikingPreferences | null,
): ['walking' | 'cycling', Readonly<Record<string, number | string>>] {
if (activity?.activity === 'bike') return ['cycling', bikeCosting(activity.profile)];
if (activity?.activity === 'run') return ['walking', runCosting(activity.profile)];
return ['walking', hikingCosting(limit, preferences)];
}
/** What went wrong, for the log. */
@@ -177,16 +139,14 @@ export class HikeRoutingService {
request: HikePlanRequest,
options?: HikePlanOptions,
): Promise<PlannedHike> {
const activity = options?.activity ?? null;
// SAC grades are a hike's: a bike tour or a run never reads them.
const withGrades = activity === null && (options?.grades ?? true);
const withGrades = options?.grades ?? true;
// The tools check their own requests; a change plans through the points a hike has saved, held here to the same.
const checked = hikePlanRequestSchema.safeParse(request);
if (!checked.success) throw badRequest(checked.error.issues.map((issue) => issue.message).join('; '));
const key = memoKeyOf(userId, tripId, request, activity);
const key = memoKeyOf(userId, tripId, request);
const kept = this.recall(key, withGrades);
if (kept) return kept;
const planned = await this.planned(userId, tripId, request, withGrades, activity);
const planned = await this.planned(userId, tripId, request, withGrades);
// Only a way with every height is kept: one without is asked again, and its heights with it.
if (planned.plan.kind === 'routed' && planned.plan.has_heights && planned.line) {
const grades = withGrades && planned.plan.grades !== null;
@@ -233,7 +193,6 @@ export class HikeRoutingService {
tripId: number,
request: HikePlanRequest,
withGrades: boolean,
activity: PlannedActivity | null,
): Promise<PlannedHike> {
const limit = request.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY;
const preferences = hikingPreferencesOf(request.hiking_preferences);
@@ -246,8 +205,8 @@ export class HikeRoutingService {
let route: RoadtripRoute;
try {
const points = request.waypoints.map(({ lat, lng }) => ({ lat, lng }));
const [mode, costingOptions] = costingOf(activity, limit, preferences);
route = await this.router.route(userId, tripId, ANY_DAY, points, mode, [], { costingOptions });
const costingOptions = hikingCosting(limit, preferences);
route = await this.router.route(userId, tripId, ANY_DAY, points, 'walking', [], { costingOptions });
} catch (err: unknown) {
if (!(err instanceof RouteRefusal)) logWarn(`[Hike] no way for trip ${tripId}: ${reasonOf(err)}`);
return unrouted(whyNot(err));
@@ -267,8 +226,6 @@ export class HikeRoutingService {
const ascent = metrics.elevationGainM;
const descent = metrics.elevationLossM;
const heights = ascent !== null && descent !== null;
// The signposts' rule is a walker's: a ride keeps the router's time for its profile, and a run's pace is set later.
const byRule = heights && activity === null;
return {
plan: {
kind: 'routed',
@@ -277,10 +234,10 @@ export class HikeRoutingService {
distance_m: Math.round(lengthM),
ascent_m: heights ? Math.round(ascent) : null,
descent_m: heights ? Math.round(descent) : null,
duration_minutes: byRule
duration_minutes: heights
? hikingTimeMinutes(metrics.distanceKm, ascent, descent)
: Math.round(route.leg.seg.duration / 60),
duration_basis: byRule ? 'din33466' : 'router',
duration_basis: heights ? 'din33466' : 'router',
has_heights: heights,
line: thinnedLine(line, HIKE_PLAN_LINE_POINTS),
profile: profileOf(line, HIKE_PLAN_PROFILE_SAMPLES),
@@ -1,158 +0,0 @@
import type { TourCreateCommand } from './tours.service';
import {
cumulativeMetres,
estimatePaddleTime,
paddleLayoutOfTrack,
paddleMovingSeconds,
recommendedPaddleSpeedKmh,
skiOptionsForDiscipline,
type GeometryPoint,
type PaddleRouteLeg,
type PaddleTourCreateRequest,
type SkiRouteLeg,
type SkiTourCreateRequest,
type SportTourPoint,
type TourWaypoint,
type TrackMarks,
} from '@trek/shared';
/**
* The pure halves of a paddle or ski tour saved from a plan
* (SportTourCreateService): the legs between its points, the portages and
* locks the paddle router found laid along the whole line, the joined line of
* a ski tour with its time, and the write each one makes, as the planner's
* save writes it.
*/
/** What a save keeps of a plan: the whole line with its heights (null without them) and a paddle tour's marks. */
export interface SportPlanned {
line: GeometryPoint[] | null;
marks: TrackMarks | null;
}
/** The legs a tour's points make, each from one point to the next. */
export function legsOf(points: readonly SportTourPoint[]): Array<{ from: SportTourPoint; to: SportTourPoint }> {
return points.slice(1).map((to, index) => ({ from: points[index] as SportTourPoint, to }));
}
type PlannedPaddleLeg = Extract<PaddleRouteLeg, { status: 'ok' }>;
/**
* The portages and locks of the planned legs, in metres along the whole
* line as the legs are joined: each leg's stretches placed by its own line,
* after the length of the legs before it. A lock has no time of its own
* entered, which the estimate names as unknown rather than zero.
*/
export function paddleMarksOf(legs: readonly PlannedPaddleLeg[]): TrackMarks {
const marks: TrackMarks = { portages: [], locks: [] };
let offset = 0;
for (const leg of legs) {
const along = cumulativeMetres(leg.geometry.map(([lat, lng]) => [lat, lng] as const));
for (const segment of leg.segments) {
const from = offset + (along[segment.from] ?? 0);
const to = offset + (along[segment.to] ?? 0);
if (segment.kind === 'portage' && to > from) marks.portages.push({ from_m: from, to_m: to, extra_minutes: null });
if (segment.kind === 'lock') {
marks.locks.push({ at_m: (from + to) / 2, wait_minutes: null, transit_minutes: null });
}
}
offset += along.at(-1) ?? 0;
}
return marks;
}
/** The length of a line in metres. */
function lengthOf(line: readonly GeometryPoint[]): number {
return cumulativeMetres(line.map(([lat, lng]) => [lat, lng] as const)).at(-1) ?? 0;
}
/**
* The write of a paddle tour, as the planner's save writes one: its boat and
* water, the speeds its time is reckoned with (the boat's starting speed where
* none is given) and that time, the moving part of the planner's estimate.
*/
export function paddleCommandOf(
request: PaddleTourCreateRequest,
waypoints: TourWaypoint[],
planned: SportPlanned,
): TourCreateCommand {
const boat = request.boat_type ?? 'kayak';
const speed = request.paddle_speed_kmh ?? recommendedPaddleSpeedKmh(boat);
const portageSpeed = request.portage_speed_kmh ?? null;
const estimate = planned.line
? estimatePaddleTime(paddleLayoutOfTrack(lengthOf(planned.line), planned.marks), {
paddleSpeedKmh: speed,
portageSpeedKmh: portageSpeed,
breakMinutes: null,
})
: null;
return {
name: request.name,
description: request.description,
tour_type: 'kayak',
route_geometry: planned.line,
duration_seconds: estimate ? paddleMovingSeconds(estimate) : null,
max_hiking_difficulty: null,
hiking_preferences: null,
boat_type: boat,
water_context: request.water_context,
paddle_speed_kmh: speed,
portage_speed_kmh: portageSpeed,
paddle_lake_style: request.lake_style ?? null,
...(request.planned_duration_minutes !== undefined
? { planned_duration_minutes: request.planned_duration_minutes }
: {}),
waypoints,
};
}
type PlannedSkiLeg = Extract<SkiRouteLeg, { status: 'ok' }>;
/**
* The planned legs of a ski tour as one line, each point with its height
* where the piste data has one, a point repeating the one before it left out,
* and the piste router's time: skiing, lifts, waiting and walking together.
*/
export function skiLineOf(legs: readonly PlannedSkiLeg[]): { line: GeometryPoint[]; seconds: number } {
const line: GeometryPoint[] = [];
for (const [lat, lng, height] of legs.flatMap((leg) => leg.geometry)) {
const last = line.at(-1);
if (last && last[0] === lat && last[1] === lng) continue;
line.push(height === null ? [lat, lng] : [lat, lng, height]);
}
const seconds = legs.reduce((sum, { seconds: parts }) => sum + parts.ski + parts.lift + parts.wait + parts.walk, 0);
return { line, seconds };
}
/**
* The write of a ski tour, as the planner's save writes one: its discipline
* (and a cross-country technique), the options the piste router was asked
* with as the discipline reads them, the build its legs were planned on, every
* leg as planned by it, and its time, given only with the line it belongs to.
*/
export function skiCommandOf(
request: SkiTourCreateRequest,
waypoints: TourWaypoint[],
planned: SportPlanned,
plan: { build: string | null; seconds: number; legs: number },
): TourCreateCommand {
const crossCountry = request.discipline === 'cross_country';
return {
name: request.name,
description: request.description,
tour_type: 'ski',
route_geometry: planned.line,
duration_seconds: planned.line ? Math.round(plan.seconds) : null,
max_hiking_difficulty: null,
hiking_preferences: null,
ski_discipline: request.discipline,
ski_technique: crossCountry ? (request.technique ?? null) : null,
ski_options: skiOptionsForDiscipline(request.discipline, request.options ?? {}),
ski_route_build: plan.build,
ski_planned_legs: Array.from({ length: plan.legs }, (_, index) => index),
...(request.planned_duration_minutes !== undefined
? { planned_duration_minutes: request.planned_duration_minutes }
: {}),
waypoints,
};
}
@@ -1,167 +0,0 @@
import { Tours } from '../../db/entities/Tours.entity';
import type { ToursRepository } from '../../db/repositories/Tours.repository';
import { logWarn } from '../audit/audit-log.logger';
import { DomainError, notFound } from '../common/domain-error';
import { PaddleRoutingService } from '../paddle-routing/paddle-routing.service';
import { RoadtripRouterService } from '../roadtrip/roadtrip-router.service';
import { SkiRoutingService } from '../ski-routing/ski-routing.service';
import { hikeWaypointsOf } from './hike-plan.helpers';
import { HikePlanService, type HikeCreated } from './hike-plan.service';
import { heightsOrNone, joinedLine } from './hike-routing.helpers';
import {
legsOf,
paddleCommandOf,
paddleMarksOf,
skiCommandOf,
skiLineOf,
type SportPlanned,
} from './sport-tour-create.helpers';
import { checkedRequest } from './tour-plan.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import {
paddleRouteRequestSchema,
paddleTourCreateRequestSchema,
skiRouteRequestSchema,
skiTourCreateRequestSchema,
type GeometryPoint,
type HikeRouteOutcome,
type PaddleTourCreateRequest,
type SkiTourCreateRequest,
} from '@trek/shared';
/** A leg the router could not plan: the tour is refused, as a 422 the tools answer with its text. */
function legRefused(index: number, code: string, where: string): DomainError {
return new DomainError(
422,
`Leg ${index + 1} could not be planned ${where} (${code}): move its points or plan it again. Nothing was saved.`,
{ code: 'tour_not_routed' },
);
}
function reasonOf(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* A paddle tour or a ski tour the assistant planned (plan_paddle_route,
* plan_ski_route) saved once the traveller said yes, the way the planner's
* save writes one. Its legs run from each point to the next and are planned
* again by the same router, on the water or on the piste data; a leg that
* cannot be planned refuses the tour and nothing is saved. The legs are
* joined into one line with the height of every point (the piste data's
* where it has them all, else Valhalla's, as for a hike); where the heights
* cannot be read the tour is saved with its points only (route_pending), and
* the planner plans it when it is opened. A paddle tour is timed with the
* planner's estimate (water at the paddling speed, each portage the router
* carries round at the carrying speed), a ski tour with the piste router's
* time. With a day it is put on that day in the same write (HikePlanService's
* save, so a tour of any type lands on its day alike).
*/
@Injectable()
export class SportTourCreateService {
constructor(
private readonly paddle: PaddleRoutingService,
private readonly ski: SkiRoutingService,
private readonly router: RoadtripRouterService,
private readonly hikes: HikePlanService,
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
) {}
async createPaddle(userId: number, tripId: number, request: PaddleTourCreateRequest): Promise<HikeCreated> {
const asked = checkedRequest<PaddleTourCreateRequest>(paddleTourCreateRequestSchema, request);
const dayId = await this.dayOf(tripId, asked.day_id);
const planned = await this.paddle.route(
paddleRouteRequestSchema.parse({
legs: legsOf(asked.waypoints),
boat: asked.boat_type ?? 'kayak',
lake_style: asked.lake_style,
lock_policy: asked.lock_policy,
}),
);
const legs = planned.legs.map((leg, index) => {
if (leg.status === 'failed') throw legRefused(index, leg.code, 'on the water');
return leg;
});
const flat = joinedLine(legs.flatMap((leg) => leg.geometry.map(([lat, lng]): [number, number] => [lat, lng])));
const line = await this.withHeights(userId, flat);
const saved = this.planned(line, paddleMarksOf(legs));
return await this.save(
tripId,
paddleCommandOf(asked, hikeWaypointsOf(asked.waypoints), saved),
dayId,
asked,
saved,
);
}
async createSki(userId: number, tripId: number, request: SkiTourCreateRequest): Promise<HikeCreated> {
const asked = checkedRequest<SkiTourCreateRequest>(skiTourCreateRequestSchema, request);
const dayId = await this.dayOf(tripId, asked.day_id);
const planned = await this.ski.route(
skiRouteRequestSchema.parse({
legs: legsOf(asked.waypoints),
discipline: asked.discipline,
technique: asked.discipline === 'cross_country' ? (asked.technique ?? null) : null,
options: asked.options ?? {},
}),
);
const legs = planned.legs.map((leg, index) => {
if (leg.status === 'failed') throw legRefused(index, leg.code, 'on the piste data');
return leg;
});
const { line, seconds } = skiLineOf(legs);
// The piste data's heights where it has one for every point, else Valhalla's, as the planner reads them.
const measured = line.every((point) => point.length === 3)
? line
: await this.withHeights(
userId,
line.map(([lat, lng]): [number, number] => [lat, lng]),
);
const saved = this.planned(measured, null);
const command = skiCommandOf(asked, hikeWaypointsOf(asked.waypoints), saved, {
build: planned.build,
seconds,
legs: legs.length,
});
return await this.save(tripId, command, dayId, asked, saved);
}
/** The joined line as it is kept: whole with its heights, or none where a height is missing. */
private planned(line: GeometryPoint[], marks: SportPlanned['marks']): SportPlanned {
const heights = line.length >= 2 && line.every((point) => point.length === 3);
return { line: heights ? line : null, marks };
}
/** The line with the height of every point, or as it was where the heights could not all be read. */
private async withHeights(userId: number, flat: Array<[number, number]>): Promise<GeometryPoint[]> {
try {
return heightsOrNone(flat, await this.router.heights(userId, flat));
} catch (err: unknown) {
logWarn(`[SportTour] no heights: ${reasonOf(err)}`);
return heightsOrNone(flat, null);
}
}
/** The trip's day a tour is put on; a day of another trip is not found. */
private async dayOf(tripId: number, dayId: number | undefined): Promise<number | null> {
if (dayId === undefined) return null;
const day = await this.toursRepo.dayOfTrip(tripId, dayId);
if (!day) throw notFound('Day not found');
return day.id;
}
private async save(
tripId: number,
command: Parameters<HikePlanService['saveOnDay']>[1],
dayId: number | null,
asked: { start_time?: string },
saved: SportPlanned,
): Promise<HikeCreated> {
// Without its heights the tour waits for its way, as a hike does when the router did not answer.
const route: HikeRouteOutcome = saved.line
? { planned: true }
: { planned: false, why: 'router_unavailable', position: null };
return await this.hikes.saveOnDay(tripId, command, dayId, asked.start_time, route);
}
}
-180
View File
@@ -1,180 +0,0 @@
import { badRequest } from '../common/domain-error';
import { hikeWaypointsOf, type KeptWay } from './hike-plan.helpers';
import type { PlannedActivity, PlannedHike } from './hike-routing.service';
import type { TourCreateCommand } from './tours.service';
import {
HIKE_ALONG_CATEGORIES,
runMinutesAtPace,
TOUR_MAX_SNAP_M,
type HikeCreateRequest,
type HikeProposal,
type HikeProposeRequest,
type HikeRouteOutcome,
type HikeStop,
type TourPlanActivity,
type TourPlanCreateRequest,
type TourPoiCategory,
type TourProposal,
type TourProposeRequest,
type UnroutedHikePlan,
} from '@trek/shared';
import type { z } from 'zod';
/**
* The pure halves of a bike tour or a run an assistant plans over MCP
* (propose_tour, create_tour), on top of a hike's (hike-plan.helpers): the
* profile it is planned for, the places looked for along it, its time on the
* move, the proposal it answers and the write it makes. One plan and one save
* for every activity; only these differ.
*/
/** The activity and profile a request plans for, the planner's own profile where none is named. */
export function plannedActivityOf(request: {
activity: TourPlanActivity;
bike_profile?: PlannedActivity['profile'];
run_profile?: PlannedActivity['profile'];
}): PlannedActivity {
if (request.activity === 'bike') {
const profile = request.bike_profile;
return {
activity: 'bike',
profile: profile === 'road' || profile === 'gravel' || profile === 'mtb' ? profile : 'touring',
};
}
return { activity: 'run', profile: request.run_profile === 'trail' ? 'trail' : 'running' };
}
/** What a proposal looks for along the way, by activity: a hike its summits and huts, a ride and a run what they stop for. */
export const ALONG_CATEGORIES: Readonly<Record<'hike' | TourPlanActivity, readonly TourPoiCategory[]>> = {
hike: HIKE_ALONG_CATEGORIES,
bike: ['drinking_water', 'bike_repair', 'food', 'viewpoint'],
run: ['drinking_water', 'toilets', 'viewpoint', 'park'],
};
/** A request of propose_tour as a hike's proposal reads it: the same points, day and words, no hiking limit. */
export function asHikeProposal({
activity: _activity,
bike_profile: _bike,
run_profile: _run,
run_pace_seconds_per_km: _pace,
...request
}: TourProposeRequest): HikeProposeRequest {
return request;
}
/** A request of create_tour as a hike's create reads it: the same points, day, words and planned time. */
export function asHikeCreate({
activity: _activity,
bike_profile: _bike,
run_profile: _run,
run_pace_seconds_per_km: _pace,
...request
}: TourPlanCreateRequest): HikeCreateRequest {
return request;
}
/**
* A bike tour's or a run's time on the move, in whole minutes: a ride the
* router's for its profile, a run its distance at the runner's pace and
* nothing else (null without a pace, never the router's walking time). Null
* where no way was planned.
*/
export function movingMinutesOf(
activity: PlannedActivity,
pace: number | null | undefined,
{ plan }: PlannedHike,
): number | null {
if (plan.kind !== 'routed') return null;
if (activity.activity === 'bike') return plan.duration_minutes;
return pace == null ? null : runMinutesAtPace(plan.distance_m / 1000, pace);
}
/**
* The proposal of a bike tour or a run, from a hike's proposal of the same
* points timed by its own time: its activity, profile and pace, how its time
* was reckoned, and nothing of a hike's limit, preferences or grades.
*/
export function tourProposalOf(
base: HikeProposal,
request: TourProposeRequest,
activity: PlannedActivity,
): TourProposal {
const bike = activity.activity === 'bike';
const pace = bike ? null : (request.run_pace_seconds_per_km ?? null);
let basis: TourProposal['duration_basis'] = null;
if (base.duration_minutes !== null) basis = bike ? 'router' : 'pace';
return {
...base,
activity: activity.activity,
bike_profile: bike ? activity.profile : null,
run_profile: bike ? null : activity.profile,
run_pace_seconds_per_km: pace,
duration_basis: basis,
max_hiking_difficulty: null,
hiking_preferences: null,
sac: null,
} as TourProposal;
}
/**
* The write of a bike tour or a run created from its points, as the
* planner's save writes one: the way it keeps with its heights, its time on
* the move, its profile (and a run's pace), and nothing of a hike's.
*/
export function tourCreateCommandOf(
request: TourPlanCreateRequest,
points: readonly HikeStop[],
way: KeptWay,
activity: PlannedActivity,
minutes: number | null,
): TourCreateCommand {
const bike = activity.activity === 'bike';
return {
name: request.name,
description: request.description,
tour_type: activity.activity,
route_geometry: way.line,
duration_seconds: way.line && minutes !== null ? minutes * 60 : null,
max_hiking_difficulty: null,
hiking_preferences: null,
bike_profile: bike ? activity.profile : null,
run_profile: bike ? null : activity.profile,
run_pace_seconds_per_km: bike ? null : (request.run_pace_seconds_per_km ?? null),
...(request.planned_duration_minutes !== undefined
? { planned_duration_minutes: request.planned_duration_minutes }
: {}),
waypoints: hikeWaypointsOf(points),
};
}
/** Why no way was planned for a ride or a run, in words that say what to do about it. */
const UNPLANNED: Readonly<Record<UnroutedHikePlan['why'], (named: string, position: number | null) => string>> = {
no_valhalla: (named) => `TREK has no Valhalla router here to plan ${named} with its profile.`,
router_unavailable: () => 'The router did not answer, for the way or for its heights; try again in a moment.',
no_way: (named) => `No way for ${named} of this profile joins these points: try another profile or move the points.`,
point_off_the_way: (_named, position) =>
`Point ${position ?? ''} is further than ${TOUR_MAX_SNAP_M} m from any way: move it onto a way.`,
too_long: (named) => `The way is too long to plan as ${named}: split it into shorter tours.`,
};
/** The refusal of a bike tour or a run whose way could not be planned: nothing is saved. */
export function unplannedTourMessage(
{ why, position }: Pick<UnroutedHikePlan, 'why' | 'position'>,
activity: TourPlanActivity,
): string {
const named = activity === 'bike' ? 'a bike tour' : 'a run';
return `${UNPLANNED[why](named, position)} Nothing was saved.`;
}
/** Whether a way that was not planned refuses the save: every reason but a router that did not answer. */
export function refusesSave(route: HikeRouteOutcome): route is Extract<HikeRouteOutcome, { planned: false }> {
return 'why' in route && route.why !== 'router_unavailable';
}
/** A request checked whole, its refinements included: an MCP tool reads only its schema's shape. */
export function checkedRequest<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
if (result.error) throw badRequest(result.error.issues.map((issue) => issue.message).join('; '));
return result.data as T;
}
-121
View File
@@ -1,121 +0,0 @@
import { ADDON_IDS } from '../../addons';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import {
McpController,
Tool,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
ok,
type McpContext,
} from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { HikePlanService } from './hike-plan.service';
import { SportTourCreateService } from './sport-tour-create.service';
import {
idSchema,
paddleTourCreateRequestSchema,
skiTourCreateRequestSchema,
tourPlanCreateRequestSchema,
tourProposeRequestSchema,
type PaddleTourCreateRequest,
type SkiTourCreateRequest,
type TourPlanCreateRequest,
type TourProposeRequest,
} from '@trek/shared';
/** What every create here says of the day it may be put on, and of where to go on from it. */
const ON_A_DAY =
'With day_id the tour is put on that day of the trip in the same write, start_time kept as the start of that visit (which needs day editing permission too); assignment names the visit, null without a day. The answer is the tour as list_tours reads it (tour.place_id is the tour id, its revision 1), route and assignment. Put it on a day later with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx. Needs place editing permission on the trip. Create it only after the traveller said yes.';
/**
* Every tour type besides a hike and a city walk planned and saved with the
* traveller over MCP: a bike tour or a run proposed and created through the
* hike's own plan and save (HikePlanService, one path for every activity on
* land), a paddle or ski tour planned with plan_paddle_route or
* plan_ski_route saved through SportTourCreateService. A proposal reads only;
* a create writes through the creation the planner's save uses, with
* 'place_edit' on the trip as REST asks (ToursController), and 'day_edit' as
* well for a tour put on a day (AssignmentsController). A refusal of the
* service is a DomainError, which the registry answers as an error result
* with the text REST sends.
*/
@McpController()
export class TourPlanMcp {
constructor(
private readonly hikes: HikePlanService,
private readonly sports: SportTourCreateService,
private readonly access: TripAccessService,
private readonly guards: McpToolGuardsService,
// Read by the addon gate of every tool here.
readonly addons: AddonsService,
) {}
/** Whether the user may create a tour in the trip, and put it on a day where one is named. */
private async mayCreate(tripId: number, userId: number, dayId: number | undefined): Promise<boolean> {
if (!(await this.guards.hasTripPermission('place_edit', tripId, userId))) return false;
// Putting it on a day is what REST's assignment route asks 'day_edit' for.
return dayId === undefined || (await this.guards.hasTripPermission('day_edit', tripId, userId));
}
@Tool({
name: 'propose_tour',
description:
"Plan a bike tour (activity bike) or a run (activity run) for a trip and show it to the traveller, without saving anything; give it a name. A hike is planned with propose_hike, a city walk with propose_city_walk, a paddle tour with plan_paddle_route, a ski or winter tour with plan_ski_route. The way runs through waypoints in order (the start, the points on the way, the end; with round_trip back to the start), planned by TREK's Valhalla router as the tour planner plans it for the profile: a bike tour's bike_profile touring (paved ways, cycle paths and quiet roads, for a city or trekking bike, the default), road (asphalt only, a road bike), gravel (gravel and farm tracks) or mtb (rough tracks and trails, a mountain bike); a run's run_profile running (runnable, mostly paved ways, the default) or trail (forest tracks and natural paths up to T2). Pick the profile from what the traveller rides or how they like to run, and ask when it is not clear. A bike tour's time (duration_minutes, duration_basis router) is the router's riding time for its profile; a run's is its distance at run_pace_seconds_per_km, the runner's own pace (duration_basis pace), and a run without a pace has no time: ask for the pace. The answer gives distance_m, ascent_m, descent_m, min_ele_m, max_ele_m, high_point, every point with at_m, snapped_m, minutes and, with start_time, its arrival, and along: water, food, toilets, viewpoints (a bike repair shop on a ride, a park on a run) near the way. With day_id or date, schedule gives the day with sunrise, sunset and the weather by the hour, and warnings name a start in the dark, an arrival after sunset, thunder or rain. Where no way was planned, route says why as propose_hike does (no_valhalla, router_unavailable, no_way, point_off_the_way, too_long). To compare ways, propose each with its own variant_label and along false, the one you recommend first. The chat shows the route, its profile, times and weather as a card: never list coordinates or the figures, sum it up in a sentence. Create it with create_tour only after the traveller said yes.",
inputSchema: { tripId: idSchema, ...tourProposeRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'read' },
})
async proposeTour({ tripId, ...request }: { tripId: number } & TourProposeRequest, ctx: McpContext) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
return ok(await this.hikes.proposeTour(ctx.userId, tripId, request));
}
@Tool({
name: 'create_tour',
description: `Create a bike tour or a run in a trip from points the traveller confirmed, usually the name, activity, profile and waypoints of a proposal of propose_tour with its round_trip. A run is created only with run_pace_seconds_per_km, the runner's pace. It is saved the way the planner saves one: a tour of type bike or run with its points in order, the way planned by the same router for the same profile and kept whole with its heights, its distance, ascent, descent and time (a bike tour the router's riding time, a run its distance at the pace), its bike_profile or run_profile and pace, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the tour is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner. Any other reason refuses the tour with why, and nothing is saved. ${ON_A_DAY}`,
inputSchema: { tripId: idSchema.describe('The trip to create the tour in'), ...tourPlanCreateRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async createTour({ tripId, ...request }: { tripId: number } & TourPlanCreateRequest, ctx: McpContext) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.mayCreate(tripId, ctx.userId, request.day_id))) return permissionDenied();
return ok(await this.hikes.createTour(ctx.userId, tripId, request));
}
@Tool({
name: 'create_paddle_tour',
description: `Create a paddle tour in a trip from a plan of plan_paddle_route the traveller confirmed: waypoints are the put-in, the points on the way and the take-out (the from of the first leg and the to of every leg, in order), with the boat_type, lake_style and lock_policy the plan was asked with, and water_context (river or lake), which the tour needs. Each leg is planned again on the water by the same router and the tour is saved as the planner saves one: its points, the whole line with its heights, its distance and its time on the move by the planner's estimate (water at paddle_speed_kmh, the boat's starting speed when left out, each portage the router carries round at portage_speed_kmh, locks without a time of their own). A leg that cannot be planned on the water refuses the tour and nothing is saved. Where the heights could not be read the tour is saved with its points only (route_pending true). ${ON_A_DAY}`,
inputSchema: {
tripId: idSchema.describe('The trip to create the tour in'),
...paddleTourCreateRequestSchema.shape,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async createPaddleTour({ tripId, ...request }: { tripId: number } & PaddleTourCreateRequest, ctx: McpContext) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.mayCreate(tripId, ctx.userId, request.day_id))) return permissionDenied();
return ok(await this.sports.createPaddle(ctx.userId, tripId, request));
}
@Tool({
name: 'create_ski_tour',
description: `Create a ski or winter tour in a trip from a plan of plan_ski_route the traveller confirmed: waypoints are the start, the points on the way and the end (the from of the first leg and the to of every leg, in order), with the discipline, technique (cross_country only, and needed there) and options the plan was asked with. Each leg is planned again on the piste data by the same router and the tour is saved as the planner saves one: its points, the whole line with its heights, its distance, climb and descent, the piste router's time (skiing, lifts, waiting and walking), its discipline and options and the piste data build it was planned on. A leg that cannot be planned refuses the tour and nothing is saved (loading: the ski area is still being downloaded, try again in a moment). Where the heights could not be read the tour is saved with its points only (route_pending true). ${ON_A_DAY}`,
inputSchema: { tripId: idSchema.describe('The trip to create the tour in'), ...skiTourCreateRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async createSkiTour({ tripId, ...request }: { tripId: number } & SkiTourCreateRequest, ctx: McpContext) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.mayCreate(tripId, ctx.userId, request.day_id))) return permissionDenied();
return ok(await this.sports.createSki(ctx.userId, tripId, request));
}
}
+1 -12
View File
@@ -21,13 +21,11 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { OverpassModule } from '../overpass/overpass.module';
import { PackingModule } from '../packing/packing.module';
import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module';
import { PaddleRoutingModule } from '../paddle-routing/paddle-routing.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { PlaceImportModule } from '../place-import/place-import.module';
import { PlacesModule } from '../places/places.module';
import { RoadtripModule } from '../roadtrip/roadtrip.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { SkiRoutingModule } from '../ski-routing/ski-routing.module';
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
import { WandererModule } from '../wanderer/wanderer.module';
import { WeatherModule } from '../weather/weather.module';
@@ -40,7 +38,6 @@ import { CollectionToursService } from './collection-tours.service';
import { HikePlanMcp } from './hike-plan.mcp';
import { HikePlanService } from './hike-plan.service';
import { HikeRoutingService } from './hike-routing.service';
import { SportTourCreateService } from './sport-tour-create.service';
import { TourActivitiesController } from './tour-activities.controller';
import { TourActivitiesMcp } from './tour-activities.mcp';
import { TourActivitiesService } from './tour-activities.service';
@@ -67,7 +64,6 @@ import { TourPackingMcp } from './tour-packing.mcp';
import { PaddleCheckCache } from './tour-paddle-check.cache';
import { TourPaddleCheckService } from './tour-paddle-check.service';
import { TourPaddlePlanService } from './tour-paddle-plan.service';
import { TourPlanMcp } from './tour-plan.mcp';
import { TourPoiSearchController } from './tour-poi-search.controller';
import { TourPoiSearchService } from './tour-poi-search.service';
import { TourSkiController } from './tour-ski.controller';
@@ -106,9 +102,7 @@ import { Module } from '@nestjs/common';
* the same writes, its way planned on the server by HikeRoutingService, its
* day read from WeatherModule's detailed weather and the places along it from
* the tours' own search; one created on a day is put there through
* AssignmentsService in the same write. A bike tour or a run goes the same
* way for its profile, and a paddle or ski tour an assistant planned is saved
* after PaddleRoutingModule's or SkiRoutingModule's router planned its legs again. A
* AssignmentsService in the same write. A
* change to a saved city walk an assistant prepares for review is laid onto
* its saved points and planned on foot by the same router, and saves nothing
* (MCP-T02); once the traveller
@@ -161,9 +155,6 @@ import { Module } from '@nestjs/common';
SchedulingModule,
// A proposed hike's day reads the detailed weather at its highest point: sun, hours and warnings.
WeatherModule,
// A paddle or ski tour the assistant planned is planned again by its own router when it is saved.
PaddleRoutingModule,
SkiRoutingModule,
MikroOrmModule.forFeature([
CollectionTours,
Tours,
@@ -221,7 +212,6 @@ import { Module } from '@nestjs/common';
CityWalkBriefingService,
HikeRoutingService,
HikePlanService,
SportTourCreateService,
TourChangePreviewService,
TourChangeSaveService,
TourChangeUndoService,
@@ -237,7 +227,6 @@ import { Module } from '@nestjs/common';
TourWalkImportMcp,
CityWalkMcp,
HikePlanMcp,
TourPlanMcp,
TourChangeMcp,
TourChangeUndoMcp,
TourActivitiesMcp,
+8 -5
View File
@@ -28,7 +28,7 @@ import { MikroORM } from '@mikro-orm/core';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import type { INestApplication } from '@nestjs/common';
import { bookWriteResultSchema } from '@trek/shared';
import { BOOK_SCHEMA_REV, bookWriteResultSchema } from '@trek/shared';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
@@ -125,6 +125,9 @@ async function journeyWithBook() {
return { owner, editor, viewer, journey, inner, everyone: [owner.id, editor.id, viewer.id].sort((a, b) => a - b) };
}
/** A revision one past what this build reads. */
const NEWER = BOOK_SCHEMA_REV + 1;
describe('Studio book over MCP and REST', () => {
it('JBE2E-001: an MCP write is what GET reads next, and a viewer is refused in the words a tool answers', async () => {
const { editor, viewer, journey, inner } = await journeyWithBook();
@@ -209,7 +212,7 @@ describe('Studio book over MCP and REST', () => {
.get(`/api/journeys/${journey.id}/book`)
.set('Cookie', authCookie(owner.id));
const row = await findRow(orm, JourneyBooks, { journey: journey.id });
const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: 2 });
const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: NEWER });
await updateRows(orm, JourneyBooks, { journey: journey.id }, { document: newer });
const saved = await request(app.getHttpServer())
@@ -220,7 +223,7 @@ describe('Studio book over MCP and REST', () => {
expect(saved.body).toEqual({
error: 'Book was saved by a newer version of TREK Studio',
code: 'book_schema_newer',
schemaRev: 2,
schemaRev: NEWER,
});
const tool = await callTool(owner.id, 'edit_book_page', {
@@ -249,12 +252,12 @@ describe('Studio book over MCP and REST', () => {
const saved = await request(app.getHttpServer())
.put(`/api/journeys/${journey.id}/book`)
.set('Cookie', authCookie(owner.id))
.send({ title: before.body.book.title, document: { ...document, schemaRev: 2 }, baseVersion: 1 });
.send({ title: before.body.book.title, document: { ...document, schemaRev: NEWER }, baseVersion: 1 });
expect(saved.status).toBe(409);
expect(saved.body).toEqual({
error: 'Book was saved by a newer version of TREK Studio',
code: 'book_schema_newer',
schemaRev: 2,
schemaRev: NEWER,
});
expect(sent()).toEqual([]);
const after = await findRow(orm, JourneyBooks, { journey: journey.id });
@@ -1,268 +0,0 @@
/**
* Tours e2e for every tour type besides a hike planned with an assistant over
* MCP: a bike tour proposed and created, a run created at its pace, a paddle
* tour and a ski tour saved from their plans, every tool called through a
* real MCP session on the whole app (buildApp, the real registry with its
* addon gates, scopes and error mapper, the real services and a migrated temp
* SQLite). The way on land is planned by the road trip's router with its
* paced queue; only the routing host behind safeFetchAdminConfigured is a
* stub answering /route and /height as Valhalla does. The legs on the water
* and on the piste data come from spies on the container's
* PaddleRoutingService and SkiRoutingService, the places along the way from
* one on TourPoiSearchService, so nothing leaves the process.
*/
import { buildApp } from '../../src/bootstrap';
import { Places } from '../../src/db/entities/Places.entity';
import { Tours } from '../../src/db/entities/Tours.entity';
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
import { withRequestContext } from '../../src/nest/database/request-context';
import { PaddleRoutingService } from '../../src/nest/paddle-routing/paddle-routing.service';
import { SkiRoutingService } from '../../src/nest/ski-routing/ski-routing.service';
import { TourPoiSearchService } from '../../src/nest/tours/tour-poi-search.service';
import { safeFetchAdminConfigured } from '../../src/utils/ssrfGuard';
import { countRows, findRow } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { spyOnRealtime, type RealtimeSpies } from '../helpers/fake-realtime';
import { valhallaRoute } from '../helpers/valhalla';
import { MikroORM } from '@mikro-orm/core';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import type { INestApplication } from '@nestjs/common';
import { BIKE_COSTING, tourProposalSchema } from '@trek/shared';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/utils/ssrfGuard')>()),
safeFetchAdminConfigured: vi.fn(),
}));
let app: INestApplication;
let orm: MikroORM;
let broadcasts: RealtimeSpies;
/** A point `k` steps of 0.0005° north of 47.9° N on 11.1° E, about 56 m a step. */
const at = (k: number): [number, number] => [47.9 + k / 2000, 11.1];
const stepsOf = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => at(from + index));
const WAYPOINTS = [
{ lat: at(0)[0], lng: 11.1, name: 'Station' },
{ lat: at(20)[0], lng: 11.1, name: 'Lake' },
];
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
/** The routing host: one leg of 1.1 km ridden in four minutes, the height of every point. */
function valhalla() {
vi.mocked(safeFetchAdminConfigured).mockImplementation(async (url, init) => {
const body = JSON.parse(String(init?.body ?? '{}')) as { shape?: Array<{ lat: number; lon: number }> };
if (url.endsWith('/route')) return json(valhallaRoute([{ line: stepsOf(0, 20), km: 1.112, seconds: 240 }]));
if (url.endsWith('/height')) {
return json({ range_height: (body.shape ?? []).map((point, index) => [index, 600 + index]) });
}
return json({}, 404);
});
}
async function moveOn(done: () => boolean): Promise<void> {
if (done()) return;
await vi.advanceTimersByTimeAsync(100);
await moveOn(done);
}
/** A call moved through to its end on the test's clock, which the router's pacing waits on. */
async function settled<T>(pending: Promise<T>): Promise<T> {
let done = false;
const outcome = pending.then(
(value) => {
done = true;
return { value };
},
(error: unknown) => {
done = true;
return { error };
},
);
await moveOn(() => done);
const result = await outcome;
if ('error' in result) throw result.error;
return result.value;
}
/** One tool call as `userId` over an MCP session of its own, with every scope; the JSON it answered or its error. */
async function callTool(userId: number, name: string, args: Record<string, unknown>) {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try {
return await settled(callOnce(userId, name, args));
} finally {
vi.useRealTimers();
}
}
async function callOnce(userId: number, name: string, args: Record<string, unknown>) {
const server = createLocalMcpServer('TREK tour types e2e');
const around = (_info: unknown, call: () => unknown): unknown => withRequestContext(orm, call);
await withRequestContext(orm, () =>
app
.get(McpRegistryService)
.attach(
server,
{ userId, scopes: null, isStaticToken: false },
{ include: (kind, tool) => kind === 'tool' && tool === name, around },
),
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'tour-types-e2e', version: '1.0.0' });
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const result = await client.callTool({ name, arguments: args });
const content = Array.isArray(result.content) ? (result.content as Array<{ text?: string }>) : [];
const text = content[0]?.text ?? '';
return result.isError === true ? { error: text } : JSON.parse(text);
} finally {
await client.close();
await server.close();
}
}
/** The events the trip was sent, emptied after each read. */
function sent(): string[] {
const events = broadcasts.broadcast.mock.calls.map((call) => String(call[1]));
broadcasts.broadcast.mockClear();
return events;
}
beforeAll(async () => {
app = await buildApp();
orm = app.get(MikroORM);
broadcasts = spyOnRealtime(app);
await setAddonEnabled(orm, 'tours', true);
vi.spyOn(app.get(TourPoiSearchService), 'search').mockResolvedValue({
pois: [],
truncated: false,
partial: false,
capped: false,
unavailable: [],
});
vi.spyOn(app.get(PaddleRoutingService), 'route').mockResolvedValue({
legs: [
{
status: 'ok',
geometry: stepsOf(0, 20),
segments: [{ kind: 'water', from: 0, to: 20, name: 'Isar', source: null, osm_ref: null, grade: null }],
distance_m: 1112,
snapped_m: { from: 1, to: 1 },
},
],
graph_rev: 'r1',
});
vi.spyOn(app.get(SkiRoutingService), 'route').mockResolvedValue({
legs: [
{
status: 'ok',
geometry: stepsOf(0, 20).map(([lat, lng], index): [number, number, number] => [lat, lng, 1800 - index * 10]),
segments: [],
distance_m: 1112,
climb_m: 0,
descent_m: 200,
seconds: { ski: 300, lift: 0, wait: 0, walk: 0 },
snapped_m: { from: 1, to: 1 },
area_ids: ['a1'],
why: [],
alternatives: [],
},
],
build: 'b1',
stale: false,
area_names: ['Garmisch'],
colour_scheme: 'europe',
attribution: '',
});
valhalla();
}, 60_000);
afterAll(async () => {
await app?.close();
});
describe('every tour type planned with an assistant over MCP', () => {
it('a bike tour is proposed for its profile and created as the planner saves one, the trip told', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Lakes' });
const proposal = await callTool(user.id, 'propose_tour', {
tripId: trip.id,
name: 'Lake ride',
activity: 'bike',
bike_profile: 'road',
waypoints: WAYPOINTS,
});
expect(tourProposalSchema.parse(proposal)).toEqual(proposal);
expect(proposal).toMatchObject({
activity: 'bike',
bike_profile: 'road',
duration_minutes: 4,
duration_basis: 'router',
});
const routeCall = vi.mocked(safeFetchAdminConfigured).mock.calls.find(([url]) => String(url).endsWith('/route'));
expect(JSON.parse(String(routeCall?.[1]?.body))).toMatchObject({
costing: 'bicycle',
costing_options: { bicycle: BIKE_COSTING.road },
});
expect(await countRows(orm, Places, { trip: trip.id })).toBe(0);
const created = await callTool(user.id, 'create_tour', {
tripId: trip.id,
name: 'Lake ride',
activity: 'bike',
bike_profile: 'road',
waypoints: WAYPOINTS,
});
expect(created.route).toEqual({ planned: true });
const tour = await findRow(orm, Tours, { place: created.tour.place_id });
expect(tour).toMatchObject({ bike_profile: 'road', duration: 4 });
expect(sent()).toEqual(['tours:changed', 'place:created']);
});
it("a run is created only with the runner's pace, and timed at it", async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Run' });
const args = { tripId: trip.id, name: 'Isar run', activity: 'run', run_profile: 'trail', waypoints: WAYPOINTS };
const refused = await callTool(user.id, 'create_tour', args);
expect(refused.error).toContain("a run is saved with the runner's pace");
const created = await callTool(user.id, 'create_tour', { ...args, run_pace_seconds_per_km: 360 });
expect(created.tour).toMatchObject({ tour_type: 'run', run_profile: 'trail', duration: 7 });
});
it('a paddle tour and a ski tour are saved from their plans with their heights and times', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Water and snow' });
const points = [
{ lat: at(0)[0], lng: 11.1 },
{ lat: at(20)[0], lng: 11.1 },
];
const paddle = await callTool(user.id, 'create_paddle_tour', {
tripId: trip.id,
name: 'Isar',
waypoints: points,
water_context: 'river',
});
expect(paddle.tour).toMatchObject({ tour_type: 'kayak', boat_type: 'kayak', water_context: 'river' });
// 1112 m at a kayak's 4 km/h.
expect(paddle.tour.duration).toBe(17);
const ski = await callTool(user.id, 'create_ski_tour', {
tripId: trip.id,
name: 'Garmisch',
waypoints: points,
discipline: 'alpine',
});
expect(ski.tour).toMatchObject({ tour_type: 'ski', ski_discipline: 'alpine', duration: 5 });
const place = await findRow(orm, Places, { id: ski.tour.place_id });
expect(JSON.parse(String(place?.route_geometry))[0]).toEqual([...at(0), 1800]);
const refused = await callTool(user.id, 'create_paddle_tour', { tripId: trip.id, name: 'X', waypoints: points });
expect(refused.error).toBeTruthy();
expect(await countRows(orm, Places, { trip: trip.id })).toBe(2);
});
});
@@ -3,9 +3,7 @@ import { Places } from '../../../src/db/entities/Places.entity';
import { TourChangeSteps } from '../../../src/db/entities/TourChangeSteps.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
import { PaddleRoutingService } from '../../../src/nest/paddle-routing/paddle-routing.service';
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
import { SkiRoutingService } from '../../../src/nest/ski-routing/ski-routing.service';
import { proposalIdOf } from '../../../src/nest/tours/tour-change-proposal';
import { makeDayAssignment } from '../../helpers/factories/itinerary';
import { makePlace } from '../../helpers/factories/places';
@@ -43,64 +41,6 @@ function routerDown(app: INestApplication): () => void {
return () => route.mockRestore();
}
/**
* No water or piste data in a test: the paddle and piste routers plan the
* one leg asked as a straight stretch, and no height host answers, so each
* tour is saved with its points only, as both tools save one then.
*/
function sportRouters(app: INestApplication): () => void {
const geometry: Array<[number, number]> = [
[47, 11],
[47.02, 11.02],
];
const paddle = vi.spyOn(app.get(PaddleRoutingService), 'route').mockResolvedValue({
legs: [
{
status: 'ok',
geometry,
segments: [{ kind: 'water', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null }],
distance_m: 2700,
snapped_m: { from: 0, to: 0 },
},
],
graph_rev: null,
});
const ski = vi.spyOn(app.get(SkiRoutingService), 'route').mockResolvedValue({
legs: [
{
status: 'ok',
geometry: geometry.map(([lat, lng]): [number, number, null] => [lat, lng, null]),
segments: [],
distance_m: 2700,
climb_m: 0,
descent_m: 0,
seconds: { ski: 600, lift: 0, wait: 0, walk: 0 },
snapped_m: { from: 0, to: 0 },
area_ids: [],
why: [],
alternatives: [],
},
],
build: null,
stale: false,
area_names: [],
colour_scheme: 'europe',
attribution: '',
});
const heights = vi.spyOn(app.get(RoadtripRouterService), 'heights').mockResolvedValue(null);
return () => {
paddle.mockRestore();
ski.mockRestore();
heights.mockRestore();
};
}
/** The two points of every tour the parity cases create. */
const TWO_POINTS = [
{ lat: 47, lng: 11 },
{ lat: 47.02, lng: 11.02 },
];
/** A hike's line with its heights, as the planner saves one. */
const HIKE_LINE: [number, number, number][] = [
[47, 11, 600],
@@ -403,56 +343,6 @@ export const toursRoadtrip: CaseGroup = {
stub: routerDown,
expect: ['tours:changed', 'place:created'],
},
{
tool: 'create_tour',
args: (s) => ({ tripId: s.trip.id, name: 'Lake ride', activity: 'bike', waypoints: TWO_POINTS }),
// The planner saves a new bike tour through the tours route, with the line it planned in the browser.
rest: {
method: 'post',
path: (s) => `/api/trips/${s.trip.id}/tours`,
body: () => ({
...hikeBody('Lake ride'),
tour_type: 'bike',
bike_profile: 'touring',
max_hiking_difficulty: null,
}),
},
stub: routerDown,
expect: ['tours:changed', 'place:created'],
},
{
tool: 'create_paddle_tour',
args: (s) => ({ tripId: s.trip.id, name: 'River day', waypoints: TWO_POINTS, water_context: 'river' }),
rest: {
method: 'post',
path: (s) => `/api/trips/${s.trip.id}/tours`,
body: () => ({
...hikeBody('River day'),
tour_type: 'kayak',
boat_type: 'kayak',
water_context: 'river',
max_hiking_difficulty: null,
}),
},
stub: sportRouters,
expect: ['tours:changed', 'place:created'],
},
{
tool: 'create_ski_tour',
args: (s) => ({ tripId: s.trip.id, name: 'Piste day', waypoints: TWO_POINTS, discipline: 'alpine' }),
rest: {
method: 'post',
path: (s) => `/api/trips/${s.trip.id}/tours`,
body: () => ({
...hikeBody('Piste day'),
tour_type: 'ski',
ski_discipline: 'alpine',
max_hiking_difficulty: null,
}),
},
stub: sportRouters,
expect: ['tours:changed', 'place:created'],
},
{
// Only the name changes, so the hike keeps its way and the router is not asked.
tool: 'update_tour',
@@ -1,137 +0,0 @@
/**
* ASTPROP-SPORT-001 to ASTPROP-SPORT-007
*
* "Plan this" on every tour type: what a proposed bike tour or run and a
* paddle or ski plan are taken as, the points a plan's legs make, the water
* and the name a paddle tour gets, and the tour type's word in the user's
* language where nothing names the tour.
*/
import {
isSportPlan,
sportTakeArgsOf,
tourTypeWord,
waypointsOfLegs,
} from '../../../../src/nest/assistant/assistant-proposal-sports';
import { takeArgsOf } from '../../../../src/nest/assistant/assistant-proposals.service';
import { writeToolOf } from '../../../../src/nest/assistant/assistant-tools.constants';
import { tourRouteCard } from '../../../helpers/assistant-tour-cards';
import { describe, expect, it } from 'vitest';
const A = { lat: 48.1, lng: 11.6 };
const B = { lat: 48.11, lng: 11.6 };
const C = { lat: 48.12, lng: 11.6 };
const ok = (segments: Array<{ kind: string; name: string | null }>) => ({ status: 'ok', segments });
describe('the points of a plan', () => {
it('ASTPROP-SPORT-001: are the first start and every end, where each leg goes on from the one before', () => {
expect(
waypointsOfLegs([
{ from: A, to: B },
{ from: B, to: C },
]),
).toEqual([A, B, C]);
// A leg that starts elsewhere is no part of one tour.
expect(
waypointsOfLegs([
{ from: A, to: B },
{ from: C, to: A },
]),
).toBeNull();
expect(waypointsOfLegs([])).toBeNull();
expect(waypointsOfLegs('legs')).toBeNull();
});
});
describe('a paddle or ski plan taken', () => {
it('ASTPROP-SPORT-002: a paddle plan is created with its boat, styles, water and the waters it runs on', () => {
const args = {
legs: [
{ from: A, to: B },
{ from: B, to: C },
],
boat: 'canoe',
lake_style: 'shore',
lock_policy: 'use',
};
const result = {
legs: [ok([{ kind: 'water', name: 'Isar' }]), ok([{ kind: 'lake', name: 'Ammersee' }])],
};
expect(sportTakeArgsOf('plan_paddle_route', args, result, 'Paddle tour')).toEqual({
via: 'create_paddle_tour',
name: 'Isar → Ammersee',
waypoints: [A, B, C],
water_context: 'river',
boat_type: 'canoe',
lake_style: 'shore',
lock_policy: 'use',
});
const lake = sportTakeArgsOf(
'plan_paddle_route',
{ legs: [{ from: A, to: B }] },
{ legs: [ok([{ kind: 'lake', name: null }])] },
'Paddeltour',
);
expect(lake).toMatchObject({ name: 'Paddeltour', water_context: 'lake' });
});
it('ASTPROP-SPORT-003: a ski plan is created with its discipline, technique and options, named by its areas', () => {
const args = {
legs: [{ from: A, to: B }],
discipline: 'cross_country',
technique: 'classic',
options: { lit_only: true },
};
expect(sportTakeArgsOf('plan_ski_route', args, { legs: [ok([])], area_names: ['Seefeld'] }, 'Ski')).toEqual({
via: 'create_ski_tour',
name: 'Seefeld',
waypoints: [A, B],
discipline: 'cross_country',
technique: 'classic',
options: { lit_only: true },
});
});
it('ASTPROP-SPORT-004: a plan with a failed leg, or legs that do not join, cannot be taken', () => {
const args = { legs: [{ from: A, to: B }], discipline: 'alpine' };
expect(sportTakeArgsOf('plan_ski_route', args, { legs: [{ status: 'failed' }] }, 'Ski')).toBeNull();
expect(sportTakeArgsOf('plan_ski_route', { ...args, legs: [] }, { legs: [] }, 'Ski')).toBeNull();
expect(isSportPlan('plan_ski_route')).toBe(true);
expect(isSportPlan('propose_hike')).toBe(false);
});
it("ASTPROP-SPORT-005: a tour nothing names is called by its type in the user's language, English for one TREK has none of", async () => {
expect(await tourTypeWord('de-DE', 'plan_paddle_route')).toBe('Paddeltour');
expect(await tourTypeWord('en', 'plan_ski_route')).toBe('Ski & winter');
expect(await tourTypeWord('xx', 'plan_paddle_route')).toBe('Paddle tour');
});
});
describe('a proposed bike tour or run taken', () => {
const card = tourRouteCard({ activity: 'bike', routingProfile: 'road' });
const args = { name: 'Lake', activity: 'bike', bike_profile: 'road', waypoints: [A, B], day_id: 3, along: false };
it('ASTPROP-SPORT-006: is created as create_tour with the arguments it was proposed with, what create_tour takes', () => {
expect(takeArgsOf({ toolName: 'propose_tour', args, result: {}, card })).toEqual({
via: 'create_tour',
name: 'Lake',
activity: 'bike',
bike_profile: 'road',
waypoints: [A, B],
day_id: 3,
});
expect(writeToolOf('take_proposal', { via: 'create_tour' })).toBe('create_tour');
expect(writeToolOf('take_proposal', { via: 'create_paddle_tour' })).toBe('create_paddle_tour');
expect(writeToolOf('take_proposal', { via: 'create_ski_tour' })).toBe('create_ski_tour');
});
it("ASTPROP-SPORT-007: a run without the runner's pace, or a way not planned, cannot be taken", () => {
const run = { ...args, activity: 'run', bike_profile: undefined, run_profile: 'trail' };
expect(takeArgsOf({ toolName: 'propose_tour', args: run, result: {}, card })).toBeNull();
expect(
takeArgsOf({ toolName: 'propose_tour', args: { ...run, run_pace_seconds_per_km: 330 }, result: {}, card }),
).toMatchObject({ via: 'create_tour', run_pace_seconds_per_km: 330 });
expect(takeArgsOf({ toolName: 'propose_tour', args, result: {}, card: { ...card, routed: false } })).toBeNull();
});
});
@@ -28,6 +28,7 @@ import {
undoReadsOf,
} from '../../../../../../src/nest/assistant/cards/registry';
import {
BOOK_FONTS_IDS,
bookDesignCardSchema,
MAX_CARD_CHARS,
type AssistantChangePreview,
@@ -250,7 +251,11 @@ describe('the designer in the Studio', () => {
// The cover tier carries a big title alone, and the faces named are faces the book has.
expect(BOOK_DESIGN_PROMPT).toContain('type.display (up to 6.5');
expect(BOOK_DESIGN_PROMPT).toContain('type.scale between 0.9 and 1.1');
expect(BOOK_DESIGN_PROMPT).not.toMatch(/script/i);
for (const face of ['caveat', 'pacifico', 'vibes', 'marker', 'playfair']) {
expect(BOOK_FONTS_IDS, face).toContain(face);
expect(BOOK_DESIGN_PROMPT, face).toContain(face);
}
expect(BOOK_DESIGN_PROMPT).toContain('a script or a mono line not counted');
expect(BOOK_DESIGN_PROMPT).toContain('210 mm page and scaled with the page width');
const rubric = BOOK_DESIGN_PROMPT.slice(BOOK_DESIGN_PROMPT.indexOf('answer silently'));
expect(rubric.slice(0, rubric.indexOf('Fix what fails')).match(/\?/g)).toHaveLength(8);
@@ -1,279 +0,0 @@
/**
* ASTCARD-TOUR-080 to ASTCARD-TOUR-088
*
* The cards of every tour type besides a hike and a city walk: a proposed
* bike tour or run (propose_tour) with its profile, time and pace, a held
* create_tour drawn as its proposal was, a held create_paddle_tour or
* create_ski_tour drawn as its plan was, the words of each create, the way
* back from each and what the tours tell the model. Every card goes through
* the registry's guarded call, so it also fits the shared card union.
*/
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
import { systemPrompt } from '../../../../../../src/nest/assistant/assistant.prompt';
import { loadFor } from '../../../../../../src/nest/assistant/cards/card-context';
import type { PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
import {
answerCardOf,
ASSISTANT_ARG_POLICY,
ASSISTANT_UNDO_MAP,
ASSISTANT_WRITE_TOOLS,
CARD_PROMPT_LINES,
} from '../../../../../../src/nest/assistant/cards/registry';
import { landSportOf } from '../../../../../../src/nest/assistant/cards/tour/tour-route.cards';
import { legArgsOf } from '../../../../../../src/nest/assistant/cards/tour/tour-sport-change.cards';
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
import { assistantChangePreviewSchema, trekCardSchema, type TourRouteCard, type TrekCard } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
vi.mock('../../../../../../src/nest/audit/audit-log.logger', () => logMock);
beforeEach(() => vi.clearAllMocks());
const card = buildTripCard(
1,
7,
{ trip: { title: 'Bavaria' }, days: [{ id: 31, day_number: 1, date: '2026-06-11', assignments: [] }] },
{ places: [] },
null,
);
const ctx: PreviewContext = { card, categories: new Map(), locale: 'en-GB', today: '2026-06-01', airport: () => null };
const POINTS = [
{ lat: 47.9, lng: 11.1, name: 'Station' },
{ lat: 47.91, lng: 11.1, name: 'Lake' },
];
/** What propose_tour answers for a gravel ride of 1.1 km. */
function proposal(extra: Record<string, unknown> = {}) {
return {
name: 'Lake loop',
variant_label: null,
route: { planned: true },
unreachable: null,
distance_m: 1112,
ascent_m: 10,
descent_m: 0,
duration_minutes: 5,
duration_basis: 'router',
heights_known: true,
min_ele_m: 600,
max_ele_m: 610,
line_geometry: [
[47.9, 11.1, 600],
[47.91, 11.1, 610],
],
sac: null,
high_point: null,
waypoints: [
{ ...POINTS[0], poi_category: null, at_m: 0, snapped_m: 1, minutes: 0, arrival: null, stage_end: false },
{ ...POINTS[1], poi_category: null, at_m: 1112, snapped_m: 1, minutes: 5, arrival: null, stage_end: false },
],
along: null,
activity: 'bike',
bike_profile: 'gravel',
run_profile: null,
run_pace_seconds_per_km: null,
max_hiking_difficulty: null,
hiking_preferences: null,
round_trip: false,
day_id: null,
date: null,
start_time: null,
schedule: null,
warnings: [],
...extra,
};
}
const input = (tool: string, value: unknown, args: Record<string, unknown> = {}) =>
[tool, { toolCallId: 'tc-1', args, value, card, today: '2026-06-01' }] as const;
function route(shown: TrekCard | null | undefined): TourRouteCard {
if (shown?.kind !== 'tourRoute') throw new Error('no route card');
expect(trekCardSchema.safeParse(shown).success).toBe(true);
return shown;
}
/** What plan_paddle_route answers for one leg on the river. */
const PADDLE_PLAN = {
legs: [
{
status: 'ok',
distance_m: 1112,
snapped_m: { from: 1, to: 1 },
points: 2,
segments: [{ kind: 'water', name: 'Isar', source: null, osm_ref: null, from_m: 0, to_m: 1112 }],
line_geometry: [
[48.1, 11.6],
[48.11, 11.6],
],
},
],
graph_rev: 'r1',
};
describe('a proposed bike tour or run', () => {
it('ASTCARD-TOUR-080: a ride names its profile and its riding time, no hiking limit or grade', () => {
const shown = route(answerCardOf(...input('propose_tour', proposal())));
expect(shown).toMatchObject({
source: 'proposal',
activity: 'bike',
routingProfile: 'gravel',
minutes: 5,
minutesBasis: 'router',
limit: null,
grades: null,
routed: true,
});
});
it('ASTCARD-TOUR-081: a run names its profile and pace, its time at that pace; one without a pace has none', () => {
const run = { activity: 'run', bike_profile: null, run_profile: 'trail', run_pace_seconds_per_km: 330 };
const shown = route(answerCardOf(...input('propose_tour', proposal({ ...run, duration_basis: 'pace' }))));
expect(shown).toMatchObject({
activity: 'run',
routingProfile: 'trail',
paceSecondsPerKm: 330,
minutesBasis: 'pace',
});
const unpaced = { ...run, run_pace_seconds_per_km: null, duration_minutes: null, duration_basis: null };
expect(route(answerCardOf(...input('propose_tour', proposal(unpaced))))).toMatchObject({
paceSecondsPerKm: null,
minutes: null,
});
});
it('ASTCARD-TOUR-082: a way that was not planned is drawn straight as the ride it is; a hike names no profile', () => {
const value = proposal({ route: { planned: false, why: 'no_way', position: null } });
expect(route(answerCardOf(...input('propose_tour', value)))).toMatchObject({
activity: 'bike',
routingProfile: 'gravel',
routed: false,
problem: { why: 'no_way' },
});
expect(landSportOf({}, {})).toBeNull();
expect(landSportOf({ activity: 'run' }, { run_profile: 'nonsense' })).toEqual({
activity: 'run',
routingProfile: 'running',
paceSecondsPerKm: null,
});
});
it('ASTCARD-TOUR-083: a saved bike tour or run names its profile on its card', () => {
const saved = {
tour: {
place_id: 9,
name: 'Lake',
tour_type: 'run',
distance: 1.1,
duration: 7,
run_profile: 'trail',
run_pace_seconds_per_km: 360,
},
waypoints: POINTS,
line: { preview: proposal().line_geometry },
};
expect(route(answerCardOf(...input('get_tour_plan', saved)))).toMatchObject({
activity: 'run',
routingProfile: 'trail',
paceSecondsPerKm: 360,
});
});
});
describe('a held new bike tour or run', () => {
const CREATE = { name: 'Lake loop', activity: 'bike', bike_profile: 'gravel', waypoints: POINTS, day_id: 31 };
it('ASTCARD-TOUR-084: reads its proposal again and shows its way, on its day, taken back by deleting it', async () => {
const read = vi.fn(async (tool: string) => (tool === 'propose_tour' ? proposal() : null));
const loaded = await loadFor('create_tour', CREATE, read);
expect(read).toHaveBeenCalledWith('propose_tour', {
along: false,
name: 'Lake loop',
activity: 'bike',
bike_profile: 'gravel',
waypoints: POINTS,
day_id: 31,
});
const preview = describeToolCall('create_tour', 'tc-2', CREATE, ctx, loaded);
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
expect(preview).toMatchObject({ verbKey: 'addTour', subject: 'Lake loop', dayId: 31, undoable: true });
expect(preview.effect).toMatchObject({ kind: 'add', dayId: 31 });
expect(route(preview.card)).toMatchObject({ source: 'pending', activity: 'bike', routingProfile: 'gravel' });
// Without its plan the points are drawn straight, still as the ride it is.
const straight = route(describeToolCall('create_tour', 'tc-3', CREATE, ctx, null).card);
expect(straight).toMatchObject({ activity: 'bike', routingProfile: 'gravel', routed: false, limit: null });
expect(ASSISTANT_WRITE_TOOLS.create_tour).toEqual({ verbKey: 'addTour', entity: 'tour' });
expect(ASSISTANT_UNDO_MAP.create_tour?.args({}, { tour: { place_id: 9 } }, null)).toEqual({ placeId: 9 });
});
});
describe('a held paddle or ski tour', () => {
const PADDLE = {
name: 'Isar',
waypoints: [
{ lat: 48.1, lng: 11.6 },
{ lat: 48.11, lng: 11.6 },
],
water_context: 'river',
boat_type: 'canoe',
};
it('ASTCARD-TOUR-085: a paddle tour reads its plan again with its legs and boat and shows it waiting', async () => {
const read = vi.fn(async (tool: string) => (tool === 'plan_paddle_route' ? PADDLE_PLAN : null));
const loaded = await loadFor('create_paddle_tour', PADDLE, read);
expect(read).toHaveBeenCalledWith('plan_paddle_route', { legs: legArgsOf(PADDLE), boat: 'canoe' });
const preview = describeToolCall('create_paddle_tour', 'tc-4', PADDLE, ctx, loaded);
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
expect(preview).toMatchObject({ verbKey: 'addTour', subject: 'Isar', undoable: true });
expect(route(preview.card)).toMatchObject({ source: 'pending', activity: 'kayak', name: 'Isar', routed: true });
expect(ASSISTANT_UNDO_MAP.create_paddle_tour?.tool).toBe('delete_place');
});
it('ASTCARD-TOUR-086: a plan with a failed leg or none at all is drawn straight, planned when the tour is added', async () => {
const failed = { legs: [{ status: 'failed', code: 'not_on_water', at: null }], graph_rev: null };
const drawn = route(describeToolCall('create_paddle_tour', 'tc-5', PADDLE, ctx, { plan: failed }).card);
expect(drawn).toMatchObject({ activity: 'kayak', routed: false, problem: { why: 'not_planned' } });
const SKI = { ...PADDLE, discipline: 'alpine', water_context: undefined, boat_type: undefined };
const read = vi.fn(async () => null);
const loaded = await loadFor('create_ski_tour', SKI, read);
expect(read).toHaveBeenCalledWith('plan_ski_route', { legs: legArgsOf(SKI), discipline: 'alpine' });
expect(route(describeToolCall('create_ski_tour', 'tc-6', SKI, ctx, loaded).card)).toMatchObject({
activity: 'ski',
routed: false,
});
// Too few points draw nothing, and there are no legs to plan.
expect(await loadFor('create_ski_tour', { name: 'X' }, read)).toBeNull();
});
it('ASTCARD-TOUR-087: legs run from each point to the next', () => {
expect(
legArgsOf({
waypoints: [
{ lat: 1, lng: 2 },
{ lat: 3, lng: 4 },
{ lat: 5, lng: 6 },
],
}),
).toEqual([
{ from: { lat: 1, lng: 2 }, to: { lat: 3, lng: 4 } },
{ from: { lat: 3, lng: 4 }, to: { lat: 5, lng: 6 } },
]);
});
});
describe('what the tours tell the model about every type', () => {
it('ASTCARD-TOUR-088: propose_tour draws its line and the prompt names a tool and profile for every type', () => {
expect(ASSISTANT_ARG_POLICY.propose_tour?.forced).toEqual({ include_geometry: true });
const every = CARD_PROMPT_LINES.find((line) => line.startsWith('Every tour type'));
for (const tool of ['propose_tour', 'create_tour', 'create_paddle_tour', 'create_ski_tour', 'plan_ski_route']) {
expect(every).toContain(tool);
}
// The profiles share the planning line: a package may add two prompt lines at most.
for (const word of ['Profiles:', 'road', 'gravel', 'mtb', 'trail', 'run_pace_seconds_per_km'])
expect(every).toContain(word);
for (const line of CARD_PROMPT_LINES) expect(systemPrompt('English')).toContain(line);
});
});
@@ -31,7 +31,7 @@ import {
import { createTestJourneyBooksRepo } from '../../helpers/journey-share-repos';
import { resetTestDb } from '../../helpers/test-db';
import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo, createTestPlacesRepo } from '../../helpers/test-uow';
import { normalizeBookDocument } from '@trek/shared';
import { BOOK_SCHEMA_REV, normalizeBookDocument } from '@trek/shared';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -561,9 +561,11 @@ describe('editBook', () => {
const journey = createJourney(testDb, user.id);
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
// The Studio stamps its revision on every save, as the tool's write does, so the race is about the version alone.
const fromStudio = { ...doc('studio'), schemaRev: BOOK_SCHEMA_REV };
const [tool, studio] = await Promise.all([
catchDomainError(() => books.editBook(journey.id, user.id, edited('tool'), { expectedVersion: 1 })),
books.saveBook(journey.id, user.id, { title: 'T', document: doc('studio'), baseVersion: 1 }),
books.saveBook(journey.id, user.id, { title: 'T', document: fromStudio, baseVersion: 1 }),
]);
const toolLost = tool instanceof DomainError && tool.getStatus() === 409;
@@ -629,6 +631,9 @@ describe('editBook', () => {
* of a server that cannot read the book whole.
*/
describe('format revisions', () => {
/** A revision one past what this build reads. */
const NEWER = BOOK_SCHEMA_REV + 1;
/** A journey whose stored book says it was written by revision `schemaRev`. */
async function storedAt(schemaRev: number) {
const { user } = createUser(testDb);
@@ -646,7 +651,7 @@ describe('format revisions', () => {
thrown instanceof DomainError ? [thrown.getStatus(), thrown.code, thrown.toBody().code] : thrown;
it('JBOOK-011: refuses a save from a writer older than the stored book with 409 and the code, writing and sending nothing', async () => {
const { user, journey } = await storedAt(2);
const { user, journey } = await storedAt(NEWER);
const rec = await recordSends();
try {
const thrown = await catchDomainError(() =>
@@ -655,11 +660,11 @@ describe('format revisions', () => {
expect(refusalOf(thrown)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
expect(thrown instanceof DomainError && thrown.toBody()).toMatchObject({
error: 'Book was saved by a newer version of TREK Studio',
schemaRev: 2,
schemaRev: NEWER,
});
expect(rec.sent).toEqual([]);
const stored = await books.getBook(journey.id, user.id);
expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', 2]);
expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', NEWER]);
} finally {
rec.restore();
}
@@ -697,7 +702,7 @@ describe('format revisions', () => {
});
it('JBOOK-014: the book tools leave a book this server cannot read whole alone, before any edit runs', async () => {
const { user, journey } = await storedAt(2);
const { user, journey } = await storedAt(NEWER);
const rec = await recordSends();
try {
const edit = vi.fn(() => ({ title: 'T', document: normalizeBookDocument(doc('tool')), result: null }));
@@ -716,7 +721,7 @@ describe('format revisions', () => {
const { user, journey } = await storedAt(1);
const rec = await recordSends();
try {
const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: 2 });
const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: NEWER });
const thrown = await catchDomainError(() =>
books.editBook(journey.id, user.id, () => ({ title: 'T', document: ahead, result: null })),
);
@@ -34,7 +34,12 @@ import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestModule, type TestModule } from '../../../helpers/test-module';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { bookDesignAnswerSchema, bookDesignApplyResultSchema, type BookDesignAnswer } from '@trek/shared';
import {
BOOK_SCHEMA_REV,
bookDesignAnswerSchema,
bookDesignApplyResultSchema,
type BookDesignAnswer,
} from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
@@ -327,7 +332,7 @@ describe('taking a design back', () => {
const applied = await designs.apply(journey.id, owner.id, { designId: answer.design_id });
const revisionId = applied.revision_id ?? 0;
const kept = await findRow(orm, JourneyBookRevisions, { id: revisionId });
const newer = JSON.stringify({ ...JSON.parse(String(kept?.document)), schemaRev: 2 });
const newer = JSON.stringify({ ...JSON.parse(String(kept?.document)), schemaRev: BOOK_SCHEMA_REV + 1 });
await updateRows(orm, JourneyBookRevisions, { id: revisionId }, { document: newer });
t.realtime.reset();
@@ -1,262 +0,0 @@
/**
* The pure halves of a bike tour, a run, a paddle or ski tour saved from its
* plan: the profile planned for, the time on the move, the proposal and the
* writes, as the planner's save writes each type.
*/
import type { PlannedHike } from '../../../src/nest/tours/hike-routing.service';
import {
legsOf,
paddleCommandOf,
paddleMarksOf,
skiCommandOf,
skiLineOf,
} from '../../../src/nest/tours/sport-tour-create.helpers';
import {
movingMinutesOf,
plannedActivityOf,
refusesSave,
tourCreateCommandOf,
unplannedTourMessage,
} from '../../../src/nest/tours/tour-plan.helpers';
import type { GeometryPoint, PaddleRouteLeg, SkiRouteLeg } from '@trek/shared';
import { describe, expect, it } from 'vitest';
// SPORT-HELP-001 to SPORT-HELP-008
const routed = (distance: number, minutes: number): PlannedHike => ({
plan: {
kind: 'routed',
max_hiking_difficulty: 2,
hiking_preferences: null,
distance_m: distance,
ascent_m: 0,
descent_m: 0,
duration_minutes: minutes,
duration_basis: 'router',
has_heights: true,
line: [
[47, 11, 500],
[47.01, 11, 500],
],
profile: [],
grades: null,
},
line: null,
snapped: null,
legs: null,
});
describe('tour-plan.helpers', () => {
it("SPORT-HELP-001: a request plans for its profile, the planner's own where none is named", () => {
expect(plannedActivityOf({ activity: 'bike' })).toEqual({ activity: 'bike', profile: 'touring' });
expect(plannedActivityOf({ activity: 'bike', bike_profile: 'mtb' })).toEqual({ activity: 'bike', profile: 'mtb' });
expect(plannedActivityOf({ activity: 'run' })).toEqual({ activity: 'run', profile: 'running' });
expect(plannedActivityOf({ activity: 'run', run_profile: 'trail' })).toEqual({ activity: 'run', profile: 'trail' });
});
it("SPORT-HELP-002: a ride keeps the router's time, a run its distance at the pace, and none without one", () => {
const plan = routed(10_000, 40);
expect(movingMinutesOf({ activity: 'bike', profile: 'road' }, null, plan)).toBe(40);
expect(movingMinutesOf({ activity: 'run', profile: 'running' }, 330, plan)).toBe(55);
expect(movingMinutesOf({ activity: 'run', profile: 'running' }, null, plan)).toBeNull();
const unrouted: PlannedHike = {
plan: { kind: 'unrouted', max_hiking_difficulty: 2, why: 'no_way', position: null, snapped_m: null },
line: null,
snapped: null,
legs: null,
};
expect(movingMinutesOf({ activity: 'bike', profile: 'road' }, null, unrouted)).toBeNull();
});
it('SPORT-HELP-003: the write names the type, its profile and pace, and nothing of a hike', () => {
const line: GeometryPoint[] = [
[47, 11, 500],
[47.01, 11, 510],
];
const points = [
{ lat: 47, lng: 11 },
{ lat: 47.01, lng: 11 },
];
const request = { name: 'Isar', activity: 'run' as const, waypoints: points, run_pace_seconds_per_km: 330 };
const command = tourCreateCommandOf(
request,
points,
{ line, seconds: 600, route: { planned: true } },
{ activity: 'run', profile: 'trail' },
55,
);
expect(command).toMatchObject({
tour_type: 'run',
run_profile: 'trail',
run_pace_seconds_per_km: 330,
bike_profile: null,
max_hiking_difficulty: null,
duration_seconds: 3300,
});
// Without its line the tour waits for its way, and so does its time.
const waiting = tourCreateCommandOf(
request,
points,
{ line: null, seconds: null, route: { planned: false, why: 'router_unavailable', position: null } },
{ activity: 'run', profile: 'trail' },
55,
);
expect(waiting.duration_seconds).toBeNull();
});
it('SPORT-HELP-004: only a router that did not answer saves a tour without its way, in words of its type', () => {
expect(refusesSave({ planned: true })).toBe(false);
expect(refusesSave({ planned: false, why: 'router_unavailable', position: null })).toBe(false);
expect(refusesSave({ planned: false, why: 'no_way', position: null })).toBe(true);
expect(unplannedTourMessage({ why: 'too_long', position: null }, 'run')).toBe(
'The way is too long to plan as a run: split it into shorter tours. Nothing was saved.',
);
expect(unplannedTourMessage({ why: 'point_off_the_way', position: 2 }, 'bike')).toContain('Point 2');
});
});
describe('sport-tour-create.helpers', () => {
const water = (from: number, to: number, kind: 'water' | 'portage' | 'lock') => ({
kind,
from,
to,
name: null,
source: null,
osm_ref: null,
grade: null,
});
it('SPORT-HELP-005: the legs run from each point to the next', () => {
const points = [
{ lat: 1, lng: 1 },
{ lat: 2, lng: 2 },
{ lat: 3, lng: 3 },
];
expect(legsOf(points)).toEqual([
{ from: points[0], to: points[1] },
{ from: points[1], to: points[2] },
]);
});
it('SPORT-HELP-006: portages and locks lie along the whole line, after the legs before them', () => {
const legs: Array<Extract<PaddleRouteLeg, { status: 'ok' }>> = [
{
status: 'ok',
geometry: [
[48, 11],
[48.01, 11],
],
segments: [water(0, 1, 'water')],
distance_m: 1112,
snapped_m: { from: 0, to: 0 },
},
{
status: 'ok',
geometry: [
[48.01, 11],
[48.02, 11],
[48.03, 11],
],
segments: [water(0, 1, 'portage'), water(1, 1, 'lock'), water(1, 2, 'water')],
distance_m: 2224,
snapped_m: { from: 0, to: 0 },
},
];
const marks = paddleMarksOf(legs);
expect(marks.portages).toHaveLength(1);
expect(Math.round(marks.portages[0]?.from_m ?? 0)).toBe(1112);
expect(Math.round(marks.portages[0]?.to_m ?? 0)).toBe(2224);
expect(Math.round(marks.locks[0]?.at_m ?? 0)).toBe(2224);
expect(marks.locks[0]).toMatchObject({ wait_minutes: null, transit_minutes: null });
const line: GeometryPoint[] = [
[48, 11, 500],
[48.01, 11, 500],
[48.02, 11, 500],
[48.03, 11, 500],
];
const request = {
name: 'Isar',
waypoints: [
{ lat: 48, lng: 11 },
{ lat: 48.03, lng: 11 },
],
water_context: 'river' as const,
};
const command = paddleCommandOf(request, [], { line, marks });
// 2224 m on the water at a kayak's 4 km/h, 1112 m carried at 2 km/h and ten minutes to get out and in.
expect(command).toMatchObject({
tour_type: 'kayak',
boat_type: 'kayak',
paddle_speed_kmh: 4,
duration_seconds: 4620,
});
expect(paddleCommandOf(request, [], { line: null, marks }).duration_seconds).toBeNull();
});
it('SPORT-HELP-007: a ski tour is one line with the piste heights and the router time, a missing height kept missing', () => {
const leg = (geometry: Array<[number, number, number | null]>): Extract<SkiRouteLeg, { status: 'ok' }> => ({
status: 'ok',
geometry,
segments: [],
distance_m: 1000,
climb_m: 0,
descent_m: 100,
seconds: { ski: 120, lift: 300, wait: 60, walk: 20 },
snapped_m: { from: 0, to: 0 },
area_ids: [],
why: [],
alternatives: [],
});
const { line, seconds } = skiLineOf([
leg([
[47, 11, 1800],
[47.01, 11, 1700],
]),
leg([
[47.01, 11, 1700],
[47.02, 11, null],
]),
]);
expect(line).toEqual([
[47, 11, 1800],
[47.01, 11, 1700],
[47.02, 11],
]);
expect(seconds).toBe(1000);
});
it('SPORT-HELP-008: the ski write keeps the discipline, its options as the discipline reads them and every leg planned', () => {
const request = {
name: 'Loipe',
waypoints: [
{ lat: 47, lng: 11 },
{ lat: 47.01, lng: 11 },
],
discipline: 'cross_country' as const,
technique: 'skating' as const,
};
const line: GeometryPoint[] = [
[47, 11, 900],
[47.01, 11, 910],
];
const command = skiCommandOf(request, [], { line, marks: null }, { build: 'b1', seconds: 1799.6, legs: 3 });
expect(command).toMatchObject({
tour_type: 'ski',
ski_discipline: 'cross_country',
ski_technique: 'skating',
ski_route_build: 'b1',
ski_planned_legs: [0, 1, 2],
duration_seconds: 1800,
});
expect(command.ski_options).toBeTruthy();
const alpine = skiCommandOf(
{ ...request, discipline: 'alpine', technique: undefined },
[],
{ line: null, marks: null },
{ build: null, seconds: 60, legs: 1 },
);
expect(alpine).toMatchObject({ ski_technique: null, duration_seconds: null });
});
});
@@ -1,148 +0,0 @@
/**
* HikeRoutingService for a bike tour and a run: the way planned through the
* same router a hike is, with the costing of the tour's profile from
* `@trek/shared` (the one the planner in the browser sends), timed by the
* router and without SAC grades. The router is the real one with its paced
* queue; only the routing host behind safeFetchAdminConfigured is a stub,
* answering /route and /height as Valhalla does.
*/
import { SettingsService } from '../../../src/nest/settings/settings.service';
import {
HikeRoutingService,
type PlannedActivity,
type PlannedHike,
} from '../../../src/nest/tours/hike-routing.service';
import { TourTrailGradeService } from '../../../src/nest/tours/tour-trail-grade.service';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { safeFetchAdminConfigured } from '../../../src/utils/ssrfGuard';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { valhallaRoute } from '../../helpers/valhalla';
import { BIKE_COSTING, RUN_COSTING, type RoutedHikePlan } from '@trek/shared';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetchAdminConfigured: vi.fn() }));
// TOUR-ROUTE-001 to TOUR-ROUTE-004
const db = createSnapshotTestDb();
const grades = vi.fn<TourTrailGradeService['grades']>();
const getUserSettings = vi.fn<(userId: number) => Promise<Record<string, unknown>>>();
let t: TestModule;
let routing: HikeRoutingService;
beforeAll(async () => {
t = await createTestModule({
db,
imports: [ToursModule],
overrides: [
{ provide: TourTrailGradeService, useValue: { grades } },
{ provide: SettingsService, useValue: { getUserSettings } },
],
});
routing = t.get(HikeRoutingService);
});
afterAll(async () => {
await t.close();
db.close();
});
const at = (k: number): [number, number] => [47 + k / 1e4, 11];
const stepsOf = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => at(from + index));
const WAYPOINTS = [0, 10, 20].map((k) => ({ lat: at(k)[0], lng: 11 }));
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
function valhalla() {
vi.mocked(safeFetchAdminConfigured)
.mockReset()
.mockImplementation(async (url, init) => {
const body = JSON.parse(String(init?.body ?? '{}')) as { shape?: Array<{ lat: number; lon: number }> };
if (url.endsWith('/route')) {
return json(
valhallaRoute([
{ line: stepsOf(0, 10), km: 0.111, seconds: 150 },
{ line: stepsOf(10, 20), km: 0.111, seconds: 180 },
]),
);
}
if (url.endsWith('/height')) {
return json({ range_height: (body.shape ?? []).map((point, index) => [index, 600 + index]) });
}
return json({}, 404);
});
}
const asked = (path: string) =>
vi.mocked(safeFetchAdminConfigured).mock.calls.filter(([url]) => String(url).endsWith(path));
const bodyOf = (path: string, index = 0) =>
JSON.parse(String(asked(path)[index]?.[1]?.body)) as Record<string, unknown>;
let trip = 0;
async function plan(activity?: PlannedActivity, sameTrip = false): Promise<PlannedHike> {
if (!sameTrip) trip += 1;
const pending = routing.plan(
trip,
trip,
{ waypoints: WAYPOINTS },
activity ? { grades: false, activity } : undefined,
);
await vi.runAllTimersAsync();
return pending;
}
function routed({ plan: planned }: PlannedHike): RoutedHikePlan {
if (planned.kind !== 'routed') throw new Error(`expected a routed plan, got ${planned.why}`);
return planned;
}
beforeEach(() => {
vi.useFakeTimers();
getUserSettings.mockReset().mockResolvedValue({});
grades.mockReset();
valhalla();
});
afterEach(() => vi.useRealTimers());
describe('HikeRoutingService: a bike tour and a run', () => {
it("TOUR-ROUTE-001: a bike tour asks the bicycle costing of its profile and keeps the router's riding time", async () => {
const planned = routed(await plan({ activity: 'bike', profile: 'gravel' }));
expect(bodyOf('/route')).toMatchObject({
costing: 'bicycle',
costing_options: { bicycle: BIKE_COSTING.gravel },
});
// 330 s by the router for the profile, never the signposts' rule a walker is timed by.
expect(planned).toMatchObject({ duration_minutes: 6, duration_basis: 'router', has_heights: true, grades: null });
expect(asked('/trace_attributes')).toHaveLength(0);
expect(grades).not.toHaveBeenCalled();
});
it('TOUR-ROUTE-002: a run asks the pedestrian costing of its profile, without a hiking limit of its own', async () => {
routed(await plan({ activity: 'run', profile: 'trail' }));
expect(bodyOf('/route')).toMatchObject({
costing: 'pedestrian',
costing_options: { pedestrian: RUN_COSTING.trail },
});
routed(await plan({ activity: 'run', profile: 'running' }));
expect(bodyOf('/route', 1)).toMatchObject({ costing_options: { pedestrian: { use_tracks: 0, use_ferry: 0 } } });
});
it('TOUR-ROUTE-003: a ride, a run and a hike through the same points are each planned on their own', async () => {
await plan();
await plan({ activity: 'bike', profile: 'road' }, true);
await plan({ activity: 'run', profile: 'running' }, true);
await plan({ activity: 'bike', profile: 'road' }, true);
// Three ways asked; the ride asked again comes from the memo.
expect(asked('/route').map((call) => (JSON.parse(String(call[1]?.body)) as { costing: string }).costing)).toEqual([
'pedestrian',
'bicycle',
'pedestrian',
]);
});
it('TOUR-ROUTE-004: where an own OSRM has no Valhalla a bike tour is refused as no_valhalla, never ridden by OSRM', async () => {
getUserSettings.mockResolvedValue({ routing_base_url: 'http://osrm.internal' });
const planned = await plan({ activity: 'bike', profile: 'touring' });
expect(planned.plan.kind === 'unrouted' && planned.plan.why).toBe('no_valhalla');
expect(asked('/route')).toHaveLength(0);
});
});
@@ -1,500 +0,0 @@
/**
* TourPlanMcp: every tour type besides a hike and a city walk planned and
* saved over MCP. A bike tour or a run is proposed and created through the
* hike's own plan and save (HikePlanService), a paddle or ski tour from its
* plan through SportTourCreateService, all real here over a snapshot test
* db. The way on land is planned by HikeRoutingService, the legs on the water
* and on the piste data by PaddleRoutingService and SkiRoutingService, and the
* heights by RoadtripRouterService: stubs in this suite. The places along the
* way and the day's weather are stubs too. The suite takes its subject from
* the container and seeds through the ORM factories.
*/
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import type { McpContext } from '../../../src/nest-mcp';
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
import { PaddleRoutingService } from '../../../src/nest/paddle-routing/paddle-routing.service';
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
import { SkiRoutingService } from '../../../src/nest/ski-routing/ski-routing.service';
import { HikeRoutingService, type PlannedHike } from '../../../src/nest/tours/hike-routing.service';
import { TourPlanMcp } from '../../../src/nest/tours/tour-plan.mcp';
import { TourPoiSearchService } from '../../../src/nest/tours/tour-poi-search.service';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { WeatherService } from '../../../src/nest/weather/weather.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { countRows, findRow } from '../../helpers/factories/rows';
import { makeDay, makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
import {
tourProposalSchema,
type GeometryPoint,
type PaddleRouteResponse,
type SkiRouteResponse,
type SkiRouteSegment,
} from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// TOUR-PLAN-MCP-001 to TOUR-PLAN-MCP-012
const db = createSnapshotTestDb();
const plan = vi.fn<HikeRoutingService['plan']>();
const search = vi.fn<TourPoiSearchService['search']>();
const getDetailed = vi.fn<WeatherService['getDetailed']>();
const paddleRoute = vi.fn<PaddleRoutingService['route']>();
const skiRoute = vi.fn<SkiRoutingService['route']>();
const heights = vi.fn<RoadtripRouterService['heights']>();
let t: TestModule;
let mcp: TourPlanMcp;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({
db,
imports: [ToursModule],
overrides: [
{ provide: HikeRoutingService, useValue: { plan } },
{ provide: TourPoiSearchService, useValue: { search } },
{ provide: WeatherService, useValue: { getDetailed } },
{ provide: PaddleRoutingService, useValue: { route: paddleRoute } },
{ provide: SkiRoutingService, useValue: { route: skiRoute } },
],
});
mcp = t.get(TourPlanMcp);
});
beforeEach(() => {
resetTestDb(db);
plan.mockReset().mockResolvedValue(routed());
search.mockReset().mockResolvedValue({ pois: [], truncated: false, partial: false, capped: false, unavailable: [] });
getDetailed.mockReset().mockRejectedValue(new Error('no weather in this suite'));
paddleRoute.mockReset().mockResolvedValue(paddled());
skiRoute.mockReset().mockResolvedValue(skied());
heights
.mockReset()
.mockImplementation(async (_userId, line) => line.map(([lat, lng]): GeometryPoint => [lat, lng, 500]));
vi.spyOn(t.get(RoadtripRouterService), 'heights').mockImplementation(heights);
t.realtime.broadcastMock.mockReset();
});
afterAll(async () => {
await t.close();
db.close();
});
const ctx = (userId: number) => ({ userId }) as McpContext;
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
const text = result.content[0]?.text ?? '';
return result.isError ? { error: text } : JSON.parse(text);
}
const LINE: GeometryPoint[] = [
[47.9, 11.1, 600],
[47.905, 11.1, 610],
[47.91, 11.1, 620],
];
/** The stub's plan: a way of 1112 m, 25 minutes by the router, every height read. */
function routed(): PlannedHike {
return {
plan: {
kind: 'routed',
max_hiking_difficulty: 2,
hiking_preferences: null,
distance_m: 1112,
ascent_m: 20,
descent_m: 0,
duration_minutes: 25,
duration_basis: 'router',
has_heights: true,
line: LINE,
profile: [
[0, 600],
[1112, 620],
],
grades: null,
},
line: LINE,
snapped: [3, 4, 5],
legs: [556, 556],
};
}
const WAYPOINTS = [
{ lat: 47.9, lng: 11.1, name: 'Station' },
{ lat: 47.905, lng: 11.1 },
{ lat: 47.91, lng: 11.1, name: 'Lake' },
];
/** Two legs on the water, the second carried round a weir halfway. */
function paddled(): PaddleRouteResponse {
const segment = (kind: 'water' | 'portage', from: number, to: number, name: string | null = 'Isar') => ({
kind,
from,
to,
name,
source: kind === 'portage' ? ('tagged' as const) : null,
osm_ref: null,
grade: null,
});
return {
legs: [
{
status: 'ok',
geometry: [
[48.1, 11.6],
[48.11, 11.6],
],
segments: [segment('water', 0, 1)],
distance_m: 1112,
snapped_m: { from: 2, to: 3 },
},
{
status: 'ok',
geometry: [
[48.11, 11.6],
[48.115, 11.6],
[48.12, 11.6],
],
segments: [segment('water', 0, 1), segment('portage', 1, 2, null)],
distance_m: 1112,
snapped_m: { from: 1, to: 2 },
},
],
graph_rev: 'r1',
};
}
const PADDLE_POINTS = [
{ lat: 48.1, lng: 11.6, name: 'Put-in' },
{ lat: 48.11, lng: 11.6 },
{ lat: 48.12, lng: 11.6, name: 'Take-out' },
];
/** One run down, its heights from the piste data, four minutes skied and a minute at the lift. */
function skied(): SkiRouteResponse {
const segment: SkiRouteSegment = {
kind: 'run',
from: 0,
to: 1,
name: 'Kandahar',
ref: null,
difficulty: 'intermediate',
lift_type: null,
grooming: [],
seconds: 240,
wait_seconds: 0,
length_m: 1112,
flags: [],
osm_way: null,
};
return {
legs: [
{
status: 'ok',
geometry: [
[47.4, 11.0, 1800],
[47.41, 11.0, 1500],
],
segments: [segment],
distance_m: 1112,
climb_m: 0,
descent_m: 300,
seconds: { ski: 240, lift: 0, wait: 60, walk: 0 },
snapped_m: { from: 1, to: 1 },
area_ids: ['a1'],
why: [],
alternatives: [],
},
],
build: 'b7',
stale: false,
area_names: ['Garmisch-Classic'],
colour_scheme: 'europe',
attribution: 'OpenStreetMap',
};
}
async function fixture() {
const { user } = await makeUser(orm());
const { user: outsider } = await makeUser(orm());
const trip = await makeTrip(orm(), user.id);
return { user, outsider, trip };
}
describe('TourPlanMcp: the tools and their gates', () => {
it('TOUR-PLAN-MCP-001: the proposal reads only, the creates write, all on the Tours addon', async () => {
const options = (method: string) => {
const entry = getEntry(TourPlanMcp as unknown as ClassRef, method);
if (!entry || entry.kind !== 'tool') throw new Error(`${method} is no MCP tool`);
return entry.options;
};
const tools = ['proposeTour', 'createTour', 'createPaddleTour', 'createSkiTour'].map(options);
expect(tools.map((tool) => [tool.name, tool.annotations?.readOnlyHint, tool.access])).toEqual([
['propose_tour', true, { group: 'trips', mode: 'read' }],
['create_tour', false, { group: 'trips', mode: 'write' }],
['create_paddle_tour', false, { group: 'trips', mode: 'write' }],
['create_ski_tour', false, { group: 'trips', mode: 'write' }],
]);
// The model is told which tool plans which type, and which profile to pick.
expect(tools[0].description).toContain('propose_hike');
expect(tools[0].description).toContain('gravel');
expect(tools[0].description).toContain('trail');
expect(tools[1].description).toContain('run_pace_seconds_per_km');
expect(tools[2].description).toContain('water_context');
expect(tools[3].description).toContain('discipline');
const isAddonEnabled = vi.fn(async (id: string) => id === 'tours');
const gated = await Promise.all(tools.map((tool) => tool.when?.(ctx(1), { addons: { isAddonEnabled } })));
expect(gated).toEqual([true, true, true, true]);
});
it('TOUR-PLAN-MCP-002: an outsider is refused every tool, the creates without place_edit or, on a day, day_edit', async () => {
const f = await fixture();
const outsider = ctx(f.outsider.id);
const noAccess = { error: 'Trip not found or access denied.' };
const bike = { tripId: f.trip.id, name: 'Lake', activity: 'bike' as const, waypoints: WAYPOINTS };
const paddle = { tripId: f.trip.id, name: 'Isar', waypoints: PADDLE_POINTS, water_context: 'river' as const };
const ski = { tripId: f.trip.id, name: 'Kandahar', waypoints: PADDLE_POINTS, discipline: 'alpine' as const };
expect(read(await mcp.proposeTour(bike, outsider))).toEqual(noAccess);
expect(read(await mcp.createTour(bike, outsider))).toEqual(noAccess);
expect(read(await mcp.createPaddleTour(paddle, outsider))).toEqual(noAccess);
expect(read(await mcp.createSkiTour(ski, outsider))).toEqual(noAccess);
const own = ctx(f.user.id);
const guards = t.get(McpToolGuardsService);
const denied = vi.spyOn(guards, 'hasTripPermission').mockResolvedValue(false);
try {
for (const result of [
await mcp.createTour(bike, own),
await mcp.createPaddleTour(paddle, own),
await mcp.createSkiTour(ski, own),
]) {
expect(result.isError).toBe(true);
}
expect(denied).toHaveBeenCalledWith('place_edit', f.trip.id, f.user.id);
} finally {
denied.mockRestore();
}
const day = await makeDay(orm(), f.trip.id, { day_number: 1, date: '2027-06-01' });
const noDayEdit = vi.spyOn(guards, 'hasTripPermission').mockImplementation(async (action) => action !== 'day_edit');
try {
expect((await mcp.createTour({ ...bike, day_id: day.id }, own)).isError).toBe(true);
expect(noDayEdit).toHaveBeenCalledWith('day_edit', f.trip.id, f.user.id);
} finally {
noDayEdit.mockRestore();
}
expect(await countRows(orm(), Places, { trip: f.trip.id })).toBe(0);
});
});
describe('TourPlanMcp: propose_tour', () => {
it('TOUR-PLAN-MCP-003: a bike tour is planned for its profile and timed by the router, nothing of a hike in it', async () => {
const f = await fixture();
const proposal = read(
await mcp.proposeTour(
{ tripId: f.trip.id, name: 'Lake', activity: 'bike', bike_profile: 'gravel', waypoints: WAYPOINTS },
ctx(f.user.id),
),
);
expect(tourProposalSchema.parse(proposal)).toEqual(proposal);
expect(proposal).toMatchObject({
activity: 'bike',
bike_profile: 'gravel',
run_profile: null,
duration_minutes: 25,
duration_basis: 'router',
max_hiking_difficulty: null,
hiking_preferences: null,
sac: null,
});
// Shared out over the legs by their length: halfway at the middle point.
expect(proposal.waypoints.map((point: { minutes: number | null }) => point.minutes)).toEqual([0, 13, 25]);
expect(plan.mock.calls[0][3]).toEqual({ grades: false, activity: { activity: 'bike', profile: 'gravel' } });
// The places a ride stops for are looked for along it, a bike repair shop among them.
expect(search).toHaveBeenCalledWith(expect.objectContaining({ tour_type: 'bike' }));
expect(search.mock.calls[0][0].categories).toContain('bike_repair');
});
it("TOUR-PLAN-MCP-004: a run is timed at the runner's pace, and has no time without one", async () => {
const f = await fixture();
const base = { tripId: f.trip.id, name: 'Isar', activity: 'run' as const, waypoints: WAYPOINTS, along: false };
const paced = read(await mcp.proposeTour({ ...base, run_pace_seconds_per_km: 360 }, ctx(f.user.id)));
expect(paced).toMatchObject({
run_profile: 'running',
run_pace_seconds_per_km: 360,
duration_minutes: 7,
duration_basis: 'pace',
});
const unpaced = read(await mcp.proposeTour({ ...base, run_profile: 'trail' }, ctx(f.user.id)));
expect(unpaced).toMatchObject({ run_profile: 'trail', duration_minutes: null, duration_basis: null });
expect(unpaced.waypoints.every((point: { minutes: number | null }) => point.minutes === null)).toBe(true);
expect(search).not.toHaveBeenCalled();
});
it('TOUR-PLAN-MCP-005: refuses a profile of the other kind, which the tool shape alone would let through', async () => {
const f = await fixture();
await expect(
mcp.proposeTour(
{ tripId: f.trip.id, name: 'X', activity: 'bike', run_profile: 'trail', waypoints: WAYPOINTS },
ctx(f.user.id),
),
).rejects.toThrow('run_profile belongs to a run');
expect(plan).not.toHaveBeenCalled();
});
});
describe('TourPlanMcp: create_tour', () => {
it('TOUR-PLAN-MCP-006: a bike tour is saved as the planner saves one, its profile and riding time kept', async () => {
const f = await fixture();
const created = read(
await mcp.createTour(
{ tripId: f.trip.id, name: 'Lake', activity: 'bike', bike_profile: 'road', waypoints: WAYPOINTS },
ctx(f.user.id),
),
);
expect(created.route).toEqual({ planned: true });
expect(created.tour).toMatchObject({
tour_type: 'bike',
bike_profile: 'road',
max_hiking_difficulty: null,
duration: 25,
route_pending: false,
});
const place = await findRow(orm(), Places, { id: created.tour.place_id });
expect(JSON.parse(String(place?.route_geometry))).toEqual(LINE);
expect(created.assignment).toBeNull();
});
it('TOUR-PLAN-MCP-007: a run is saved with its pace and the time at that pace, put on its day with its start', async () => {
const f = await fixture();
const day = await makeDay(orm(), f.trip.id, { day_number: 1, date: '2027-06-01' });
const created = read(
await mcp.createTour(
{
tripId: f.trip.id,
name: 'Isar',
activity: 'run',
run_profile: 'trail',
run_pace_seconds_per_km: 360,
waypoints: WAYPOINTS,
day_id: day.id,
start_time: '07:30',
},
ctx(f.user.id),
),
);
expect(created.tour).toMatchObject({
tour_type: 'run',
run_profile: 'trail',
run_pace_seconds_per_km: 360,
duration: 7,
});
expect(created.assignment).toEqual({ id: expect.any(Number), day_id: day.id });
const visit = await findRow(orm(), DayAssignments, { id: created.assignment.id });
expect(visit?.assignment_time).toBe('07:30');
});
it("TOUR-PLAN-MCP-008: refuses a run without the runner's pace and a way no profile joins; nothing is saved", async () => {
const f = await fixture();
await expect(
mcp.createTour({ tripId: f.trip.id, name: 'Isar', activity: 'run', waypoints: WAYPOINTS }, ctx(f.user.id)),
).rejects.toThrow("a run is saved with the runner's pace");
plan.mockResolvedValue({
plan: { kind: 'unrouted', max_hiking_difficulty: 2, why: 'no_way', position: null, snapped_m: null },
line: null,
snapped: null,
legs: null,
});
await expect(
mcp.createTour({ tripId: f.trip.id, name: 'Lake', activity: 'bike', waypoints: WAYPOINTS }, ctx(f.user.id)),
).rejects.toThrow('No way for a bike tour of this profile joins these points');
expect(await countRows(orm(), Places, { trip: f.trip.id })).toBe(0);
});
});
describe('TourPlanMcp: create_paddle_tour', () => {
it('TOUR-PLAN-MCP-009: saves the legs planned again on the water with their heights and the estimate with the portage', async () => {
const f = await fixture();
const created = read(
await mcp.createPaddleTour(
{ tripId: f.trip.id, name: 'Isar', waypoints: PADDLE_POINTS, water_context: 'river', boat_type: 'canoe' },
ctx(f.user.id),
),
);
expect(paddleRoute).toHaveBeenCalledWith(
expect.objectContaining({
boat: 'canoe',
legs: [
{ from: PADDLE_POINTS[0], to: PADDLE_POINTS[1] },
{ from: PADDLE_POINTS[1], to: PADDLE_POINTS[2] },
],
}),
);
expect(created.route).toEqual({ planned: true });
expect(created.tour).toMatchObject({ tour_type: 'kayak', boat_type: 'canoe', water_context: 'river' });
// Water at the canoe's speed, the portage carried at 2 km/h with its ten minutes for getting out and in.
expect(created.tour.duration).toBeGreaterThan(10);
const place = await findRow(orm(), Places, { id: created.tour.place_id });
const line = JSON.parse(String(place?.route_geometry)) as GeometryPoint[];
// The legs joined into one line, the point they share once.
expect(line).toHaveLength(4);
expect(line.every((point) => point[2] === 500)).toBe(true);
});
it('TOUR-PLAN-MCP-010: refuses a leg the water router cannot plan, and waits for its way without heights', async () => {
const f = await fixture();
const request = { tripId: f.trip.id, name: 'Isar', waypoints: PADDLE_POINTS, water_context: 'lake' as const };
paddleRoute.mockResolvedValueOnce({
legs: [paddled().legs[0], { status: 'failed', code: 'not_on_water', at: null }],
graph_rev: null,
});
await expect(mcp.createPaddleTour(request, ctx(f.user.id))).rejects.toThrow(
'Leg 2 could not be planned on the water (not_on_water)',
);
expect(await countRows(orm(), Places, { trip: f.trip.id })).toBe(0);
heights.mockRejectedValueOnce(new Error('height host down'));
const waiting = read(await mcp.createPaddleTour(request, ctx(f.user.id)));
expect(waiting.route).toEqual({ planned: false, why: 'router_unavailable', position: null });
expect(waiting.tour.route_pending).toBe(true);
});
});
describe('TourPlanMcp: create_ski_tour', () => {
it('TOUR-PLAN-MCP-011: saves the legs planned again on the piste data with its heights, its time, build and options', async () => {
const f = await fixture();
const points = PADDLE_POINTS.slice(0, 2);
const created = read(
await mcp.createSkiTour(
{ tripId: f.trip.id, name: 'Kandahar', waypoints: points, discipline: 'alpine' },
ctx(f.user.id),
),
);
expect(created.tour).toMatchObject({ tour_type: 'ski', ski_discipline: 'alpine', duration: 5 });
// The piste data had a height for every point: Valhalla is not asked.
expect(heights).not.toHaveBeenCalled();
const place = await findRow(orm(), Places, { id: created.tour.place_id });
expect(JSON.parse(String(place?.route_geometry))).toEqual([
[47.4, 11.0, 1800],
[47.41, 11.0, 1500],
]);
});
it('TOUR-PLAN-MCP-012: refuses a cross-country tour without its technique and a leg the piste router cannot plan', async () => {
const f = await fixture();
const base = { tripId: f.trip.id, name: 'Loipe', waypoints: PADDLE_POINTS.slice(0, 2) };
await expect(mcp.createSkiTour({ ...base, discipline: 'cross_country' }, ctx(f.user.id))).rejects.toThrow(
'a cross-country tour needs a technique',
);
skiRoute.mockResolvedValueOnce({
...skied(),
legs: [{ status: 'failed', code: 'loading', at: null, hint: null, detail: null }],
});
await expect(mcp.createSkiTour({ ...base, discipline: 'alpine' }, ctx(f.user.id))).rejects.toThrow(
'Leg 1 could not be planned on the piste data (loading)',
);
expect(await countRows(orm(), Places, { trip: f.trip.id })).toBe(0);
});
});
@@ -157,24 +157,6 @@ describe('tourRouteCardSchema', () => {
});
});
describe('tourRouteCardSchema: bike tours and runs', () => {
it('TOURCARD-008: a bike tour or a run names its profile, a run its pace; a card drawn before reads without both', () => {
const bike = hike({
activity: 'bike',
routingProfile: 'gravel',
minutesBasis: 'router',
limit: null,
grades: null,
});
expect(tourRouteCardSchema.parse(bike).routingProfile).toBe('gravel');
const run = hike({ activity: 'run', routingProfile: 'trail', minutesBasis: 'pace', paceSecondsPerKm: 330 });
expect(tourRouteCardSchema.parse(run).paceSecondsPerKm).toBe(330);
expect(tourRouteCardSchema.parse(hike()).routingProfile).toBeUndefined();
expect(tourRouteCardSchema.safeParse(hike({ routingProfile: 'tandem' as never })).success).toBe(false);
expect(tourRouteCardSchema.safeParse(hike({ paceSecondsPerKm: 5 })).success).toBe(false);
});
});
describe('tourListCardSchema', () => {
const row = {
placeId: 61,
+2 -18
View File
@@ -3,12 +3,7 @@ import { skiColourSchemeSchema, skiRunDifficultySchema, skiSegmentKindSchema } f
import { HIKE_UNROUTED_REASONS } from '../../tours/hike-plan.schema';
import { HIKE_WARNING_KINDS } from '../../tours/hike-schedule';
import { tourPoiCategorySchema } from '../../tours/tour-poi-category';
import {
BIKE_PROFILES,
RUN_PROFILES,
tourMaxHikingDifficultySchema,
tourTypeKeySchema,
} from '../../tours/tours.schema';
import { tourMaxHikingDifficultySchema, tourTypeKeySchema } from '../../tours/tours.schema';
import { cardClockSchema, cardDateSchema, cardLatSchema, cardLngSchema, cardPointSchema, cardText } from './card-parts';
import { z } from 'zod';
@@ -100,9 +95,6 @@ export const TOUR_WARNING_KINDS = [...HIKE_WARNING_KINDS, 'legs_failed'] as cons
*/
export const TOUR_ROUTE_PROBLEMS = [...HIKE_UNROUTED_REASONS, 'ferry_only', 'not_planned'] as const;
/** The profile a bike tour or a run is planned for, as the Tours tab names it. */
export const TOUR_ROUTE_PROFILES = [...BIKE_PROFILES, ...RUN_PROFILES] as const;
export const tourRouteScheduleSchema = z.object({
date: cardDateSchema,
dayId: idSchema.nullable(),
@@ -144,15 +136,7 @@ export const tourRouteCardSchema = z.object({
minEleM: height.nullable(),
maxEleM: height.nullable(),
minutes: minutes.nullable(),
/** pace: a run's distance at the runner's pace. */
minutesBasis: z.enum(['din33466', 'router', 'planned', 'stations', 'piste', 'pace']).nullable(),
/**
* A bike tour's or a run's profile (road, gravel, trail ...); null for every
* other activity. Left out by a card drawn before it was sent.
*/
routingProfile: z.enum(TOUR_ROUTE_PROFILES).nullable().optional(),
/** A run's pace in seconds per kilometre; null without one. Left out like the profile. */
paceSecondsPerKm: z.number().int().min(60).max(3_600).nullable().optional(),
minutesBasis: z.enum(['din33466', 'router', 'planned', 'stations', 'piste']).nullable(),
/** The hiking limit the way was planned under, a setting rather than a grade of the path. */
limit: tourMaxHikingDifficultySchema.nullable(),
grades: z.object({ highest: sacStep.nullable(), unknownShare: z.number().min(0).max(1) }).nullable(),
+2 -1
View File
@@ -25,6 +25,7 @@ import {
bookStrokeStyleSchema,
bookTextAlignSchema,
bookTypesetFontSchema,
bookWeightSchema,
} from './book-op-enums';
import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './book-op-ids';
import {
@@ -99,7 +100,7 @@ const PHOTO_PROPS = {
export const TEXT_STYLE_FIELDS = {
font: bookFontIdSchema.optional(),
size: text.size.unwrap().optional(),
weight: text.weight.unwrap().optional(),
weight: bookWeightSchema.optional(),
italic: text.italic.unwrap().optional(),
align: bookTextAlignSchema.optional(),
leading: text.leading.unwrap().optional(),
@@ -140,6 +140,12 @@ describe('book level and the page in words', () => {
const mixOf = (pages: typeof fonts) => bookLevelFindings(bookOf(pages)).filter((f) => f.code === 'font_mix');
expect(mixOf(fonts.slice(0, 3))).toEqual([]);
expect(mixOf(fonts.slice(0, 2))).toEqual([]);
// A script laid over a title and a mono line on a ticket dress a page; they are not a fourth voice.
const dressed = ['caveat', 'mono'].map((font, i) => [
text(`d-${i}`, 'Hi', { x: 230, y: 40, w: 60, h: 10 }, { font }),
]);
expect(mixOf([...fonts.slice(0, 3), ...dressed])).toEqual([]);
expect(mixOf([...fonts, ...dressed])[0]?.message).toMatch(/4 typefaces \(bebas, playfair, sans, serif\)/);
});
it('BOOKQA-010: notes colours outside the book own and the same page three times running', () => {
+12 -14
View File
@@ -1,5 +1,6 @@
import { compareCodeUnits } from '../common/compare';
import { contrastOfLuminance, deltaE, greyLuminance, luminance, parseHex, requiredContrast } from './book-colour';
import { bookFontCountsInMix, bookFontsUsed } from './book-fonts';
import type { BookPhotoInfo } from './book-ops-core';
import { BOOK_OP_LIMITS, type BookFinding } from './book-ops.schema';
import type { BookDocument, BookElement, BookFrame, BookPageSetup, BookPhotoElement, BookSpread } from './book.schema';
@@ -306,16 +307,13 @@ export function designFindings(
return sortBookFindings(ctx.out);
}
/** The typefaces a book is set in: its texts, its travel elements and its page numbers when shown. */
/**
* The typefaces that make a book's voice: its texts, its travel elements and
* its page numbers when shown, leaving out the scripts and mono faces, which
* dress a page rather than set it.
*/
function familiesOf(doc: BookDocument): string[] {
const used = new Set<string>();
for (const spread of doc.spreads) {
for (const el of spread.elements) {
if ('font' in el && (el.kind !== 'text' || el.text.trim())) used.add(el.font);
}
}
if (doc.page.pageNumbers.show) used.add(doc.page.pageNumbers.font);
return [...used].sort(compareCodeUnits);
return bookFontsUsed(doc).filter(bookFontCountsInMix).sort(compareCodeUnits);
}
/** The colours a book paints with: its type and its filled shapes, with how often each is used. */
@@ -342,10 +340,10 @@ function signatureOf(spread: BookSpread): string {
/**
* How many typefaces a book may be set in before it reads as a type sampler:
* a display face, a body face and one more, a script laid over a title or a
* face for figures. Two was the rule while the book had seven faces and no
* scripts, and it told every lively book that set its title apart to stop.
* Once fonts carry roles, script and mono faces will not count towards it.
* a display face, a body face and one more for figures or a subtitle. Two was
* the rule while the book had seven faces and no scripts, and it told every
* lively book that set its title apart to stop. Script and mono faces do not
* count towards it (`bookFontCountsInMix`).
*/
const MAX_BOOK_FAMILIES = 3;
@@ -358,7 +356,7 @@ export function bookLevelFindings(doc: BookDocument): BookFinding[] {
code: 'font_mix',
severity: 'warn',
elementIds: [],
message: `the book is set in ${families.length} typefaces (${families.join(', ')}); keep to three: a display face, a body face and one more`,
message: `the book is set in ${families.length} typefaces (${families.join(', ')}); keep to three: a display face, a body face and one more; scripts and mono faces do not count`,
});
}
const colours = [...coloursOf(doc)].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
+4 -11
View File
@@ -1,4 +1,5 @@
import { bookVisiblePart, sortBookFindings } from './book-findings-design';
import { bookFontFace } from './book-fonts';
import type { BookPhotoInfo } from './book-ops-core';
import type { BookFinding } from './book-ops.schema';
import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread, BookTextElement } from './book.schema';
@@ -66,16 +67,8 @@ export function photoDpi(el: BookPhotoElement, info: BookPhotoInfo | undefined):
return Math.round(25.4 / mmPerPx);
}
/** Average letter width per em at weight 400, per family: measured on story text as the Studio sets each family. */
const LETTER_EM: Record<BookTextElement['font'], number> = {
sans: 0.507,
inter: 0.478,
serif: 0.469,
garamond: 0.383,
playfair: 0.454,
display: 0.492,
bebas: 0.339,
};
/** Average letter width per em at weight 400, per family: the catalogue's measure (book-fonts.data.ts). */
const letterEm = (font: string) => bookFontFace(font)?.letterEm ?? 0.5;
/**
* The headroom on letter width the design engine sizes a text frame with. A
@@ -121,7 +114,7 @@ function wrappedLines(para: string, perLine: number): number {
export function textHeightNeeded(el: BookTextElement, headroom = 1): number {
const sizeMm = (orDefault(el.size, 11) * 25.4) / 72;
const tracking = orDefault(el.tracking, 0);
const letter = sizeMm * ((LETTER_EM[el.font] ?? 0.5) * (el.weight >= 600 ? 1.06 : 1) * headroom + tracking);
const letter = sizeMm * (letterEm(el.font) * (el.weight >= 600 ? 1.06 : 1) * headroom + tracking);
const perLine = Math.max(1, Math.floor(el.frame.w / Math.max(letter, 0.1)));
const lines = el.text.split('\n').reduce((sum, para) => sum + wrappedLines(para.trimEnd(), perLine), 0);
return lines * sizeMm * orDefault(el.leading, 1.45);
+481
View File
@@ -0,0 +1,481 @@
/**
* The typefaces a book may be set in, and what each of them is.
*
* One catalogue for every reader of a book: the schema takes its ids, the
* Studio draws its picker and its stacks from it, the findings count its
* roles, the design engine checks its weights, and the assistant reads the
* same lines in the tool schemas. A family named in one of those places and
* missing from another is how five of the first seven fonts shipped without
* their faces (#2183), so nothing here is copied anywhere else.
*
* Every family is self-hosted through @fontsource and licensed under the SIL
* Open Font License 1.1 or the Apache License 2.0 (the typefaces in NOTICE.md),
* which both allow embedding in a printed or exported book.
*
* This file holds data only, so book.schema.ts can take the ids from it
* without importing the logic in book-fonts.ts.
*/
/** Every weight CSS names. A family offers the ones it really ships. */
export const BOOK_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const;
export type BookWeight = (typeof BOOK_WEIGHTS)[number];
/**
* What a family is for, most suited first.
*
* `display` sets a cover title or a poster word, `heading` a page's title,
* `body` running text at 9 to 11 pt. A `script` is laid over a title or a
* photo, a `mono` sets tickets and coordinates. Neither of the last two
* counts towards the faces a book is set in (`font_mix`), because they are
* ornaments of a page rather than its voice.
*/
export const BOOK_FONT_ROLES = ['display', 'heading', 'body', 'script', 'mono'] as const;
export type BookFontRole = (typeof BOOK_FONT_ROLES)[number];
/** The picker's groups, in the order it shows them. */
export const BOOK_FONT_CATEGORIES = ['sans', 'serif', 'elegant', 'condensed', 'retro', 'script', 'mono'] as const;
export type BookFontCategory = (typeof BOOK_FONT_CATEGORIES)[number];
export interface BookFontFace {
/** What it is called, shown as itself in the picker. */
name: string;
/** The CSS stack. Ends in a generic, never in a font that is not bundled. */
stack: string;
/** The @fontsource package the faces come from. */
pkg: string;
category: BookFontCategory;
/** What it suits, the first being what it is for. */
roles: readonly BookFontRole[];
/**
* Weights actually loaded. Asking for 700 on a family that ships one weight
* gets a synthesised bold, which prints as a smeared version of the regular.
*/
weights: readonly BookWeight[];
/** The weights it has a real italic for. Any other italic is the browser's slant. */
italics: readonly BookWeight[];
/**
* Average letter width per em at weight 400, measured on story text from
* the face's own advance widths, for the text measure in book-findings.ts.
*/
letterEm: number;
/** Set in capitals whatever is typed. */
caps?: true;
}
const ALL = BOOK_WEIGHTS;
const ONE = [400] as const;
const MID = [400, 500, 600, 700] as const;
/**
* The families. `sans`, `serif` and `display` are the three the format started
* with and keep their names, because documents already contain them.
*/
export const BOOK_FONT_FACES = {
// ── sans ──
sans: {
name: 'Poppins',
stack: '"Poppins", system-ui, sans-serif',
pkg: 'poppins',
category: 'sans',
roles: ['heading', 'body', 'display'],
weights: ALL,
italics: [],
letterEm: 0.507,
},
inter: {
name: 'Inter',
stack: '"Inter", system-ui, sans-serif',
pkg: 'inter',
category: 'sans',
roles: ['body', 'heading'],
weights: ALL,
italics: [],
letterEm: 0.478,
},
montserrat: {
name: 'Montserrat',
stack: '"Montserrat", system-ui, sans-serif',
pkg: 'montserrat',
category: 'sans',
roles: ['heading', 'body', 'display'],
weights: ALL,
italics: [],
letterEm: 0.507,
},
// ── serif ──
serif: {
name: 'Lora',
stack: '"Lora", Georgia, serif',
pkg: 'lora',
category: 'serif',
roles: ['body', 'heading'],
weights: MID,
italics: MID,
letterEm: 0.469,
},
garamond: {
name: 'EB Garamond',
stack: '"EB Garamond", Georgia, serif',
pkg: 'eb-garamond',
category: 'serif',
roles: ['body', 'heading'],
weights: [400, 500, 600, 700, 800],
italics: [400, 500, 600, 700, 800],
letterEm: 0.383,
},
// ── elegant display serifs ──
playfair: {
name: 'Playfair Display',
stack: '"Playfair Display", Georgia, serif',
pkg: 'playfair-display',
category: 'elegant',
roles: ['display', 'heading'],
weights: [400, 500, 600, 700, 800, 900],
italics: [400, 500, 600, 700, 800, 900],
letterEm: 0.454,
},
fraunces: {
name: 'Fraunces',
stack: '"Fraunces", Georgia, serif',
pkg: 'fraunces',
category: 'elegant',
roles: ['display', 'heading', 'body'],
weights: ALL,
italics: ALL,
letterEm: 0.467,
},
cormorant: {
name: 'Cormorant Garamond',
stack: '"Cormorant Garamond", Georgia, serif',
pkg: 'cormorant-garamond',
category: 'elegant',
roles: ['display', 'heading'],
weights: [300, 400, 500, 600, 700],
italics: [300, 400, 500, 600, 700],
letterEm: 0.393,
},
dmserif: {
name: 'DM Serif Display',
stack: '"DM Serif Display", Georgia, serif',
pkg: 'dm-serif-display',
category: 'elegant',
roles: ['display'],
weights: ONE,
italics: ONE,
letterEm: 0.448,
},
bodoni: {
name: 'Bodoni Moda',
stack: '"Bodoni Moda", Georgia, serif',
pkg: 'bodoni-moda',
category: 'elegant',
roles: ['display', 'heading'],
weights: [400, 500, 600, 700, 800, 900],
italics: [400, 500, 600, 700, 800, 900],
letterEm: 0.463,
},
// ── condensed and poster ──
bebas: {
name: 'Bebas Neue',
stack: '"Bebas Neue", Impact, sans-serif',
pkg: 'bebas-neue',
category: 'condensed',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.339,
caps: true,
},
anton: {
name: 'Anton',
stack: '"Anton", Impact, sans-serif',
pkg: 'anton',
category: 'condensed',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.409,
},
oswald: {
name: 'Oswald',
stack: '"Oswald", Impact, sans-serif',
pkg: 'oswald',
category: 'condensed',
roles: ['display', 'heading'],
weights: [200, 300, 400, 500, 600, 700],
italics: [],
letterEm: 0.365,
},
league: {
name: 'League Gothic',
stack: '"League Gothic", Impact, sans-serif',
pkg: 'league-gothic',
category: 'condensed',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.266,
},
shoulders: {
name: 'Big Shoulders Display',
stack: '"Big Shoulders Display", Impact, sans-serif',
pkg: 'big-shoulders-display',
category: 'condensed',
roles: ['display', 'heading'],
weights: ALL,
italics: [],
letterEm: 0.304,
},
archivo: {
name: 'Archivo Black',
stack: '"Archivo Black", Impact, sans-serif',
pkg: 'archivo-black',
category: 'condensed',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.557,
},
// ── heavy and retro display ──
display: {
name: 'MuseoModerno',
stack: '"MuseoModerno", "Poppins", system-ui, sans-serif',
pkg: 'museomoderno',
category: 'retro',
roles: ['display', 'heading'],
weights: ALL,
italics: [],
letterEm: 0.492,
},
shrikhand: {
name: 'Shrikhand',
stack: '"Shrikhand", system-ui, sans-serif',
pkg: 'shrikhand',
category: 'retro',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.529,
},
alfa: {
name: 'Alfa Slab One',
stack: '"Alfa Slab One", Georgia, serif',
pkg: 'alfa-slab-one',
category: 'retro',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.54,
},
bricolage: {
name: 'Bricolage Grotesque',
stack: '"Bricolage Grotesque", system-ui, sans-serif',
pkg: 'bricolage-grotesque',
category: 'retro',
roles: ['display', 'heading', 'body'],
weights: [200, 300, 400, 500, 600, 700, 800],
italics: [],
letterEm: 0.471,
},
abril: {
name: 'Abril Fatface',
stack: '"Abril Fatface", Georgia, serif',
pkg: 'abril-fatface',
category: 'retro',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.46,
},
// ── script and handwriting ──
caveat: {
name: 'Caveat',
stack: '"Caveat", cursive',
pkg: 'caveat',
category: 'script',
roles: ['script'],
weights: MID,
italics: [],
letterEm: 0.344,
},
dancing: {
name: 'Dancing Script',
stack: '"Dancing Script", cursive',
pkg: 'dancing-script',
category: 'script',
roles: ['script'],
weights: MID,
italics: [],
letterEm: 0.363,
},
pacifico: {
name: 'Pacifico',
stack: '"Pacifico", cursive',
pkg: 'pacifico',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.419,
},
vibes: {
name: 'Great Vibes',
stack: '"Great Vibes", cursive',
pkg: 'great-vibes',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.284,
},
sacramento: {
name: 'Sacramento',
stack: '"Sacramento", cursive',
pkg: 'sacramento',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.308,
},
kaushan: {
name: 'Kaushan Script',
stack: '"Kaushan Script", cursive',
pkg: 'kaushan-script',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.396,
},
allura: {
name: 'Allura',
stack: '"Allura", cursive',
pkg: 'allura',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.33,
},
parisienne: {
name: 'Parisienne',
stack: '"Parisienne", cursive',
pkg: 'parisienne',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.361,
},
satisfy: {
name: 'Satisfy',
stack: '"Satisfy", cursive',
pkg: 'satisfy',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.382,
},
yellowtail: {
name: 'Yellowtail',
stack: '"Yellowtail", cursive',
pkg: 'yellowtail',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.333,
},
homemade: {
name: 'Homemade Apple',
stack: '"Homemade Apple", cursive',
pkg: 'homemade-apple',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.561,
},
marker: {
name: 'Permanent Marker',
stack: '"Permanent Marker", cursive',
pkg: 'permanent-marker',
category: 'script',
roles: ['script', 'display'],
weights: ONE,
italics: [],
letterEm: 0.516,
},
// ── mono ──
mono: {
name: 'DM Mono',
stack: '"DM Mono", ui-monospace, monospace',
pkg: 'dm-mono',
category: 'mono',
roles: ['mono'],
weights: [300, 400, 500],
italics: [],
letterEm: 0.613,
},
} as const satisfies Record<string, BookFontFace>;
export type BookFontFamily = keyof typeof BOOK_FONT_FACES;
/**
* The families a book may be set in, as the schema takes them. The first
* seven keep the order they shipped in; new ones follow.
*/
export const BOOK_FONTS_IDS = [
'sans',
'inter',
'serif',
'garamond',
'playfair',
'display',
'bebas',
'montserrat',
'fraunces',
'cormorant',
'dmserif',
'bodoni',
'anton',
'oswald',
'league',
'shoulders',
'archivo',
'shrikhand',
'alfa',
'bricolage',
'abril',
'caveat',
'dancing',
'pacifico',
'vibes',
'sacramento',
'kaushan',
'allura',
'parisienne',
'satisfy',
'yellowtail',
'homemade',
'marker',
'mono',
] as const satisfies readonly BookFontFamily[];
/**
* The faces whose latin files the service worker installs with the app, so a
* book set in them opens offline before Studio was ever opened online: the
* seven families and weights the book shipped with before this catalogue grew.
* Every other face is cached the first time a book that uses it is opened.
*/
export const BOOK_FONTS_PRECACHED: Readonly<Partial<Record<BookFontFamily, readonly BookWeight[]>>> = {
sans: MID,
inter: MID,
serif: MID,
garamond: MID,
playfair: MID,
display: MID,
bebas: ONE,
};
+207
View File
@@ -0,0 +1,207 @@
import {
bookFacesUsed,
bookFontCountsInMix,
bookFontFace,
bookFontHasItalic,
bookFontHasWeight,
bookFontIndex,
bookFontRole,
bookFontSuits,
bookFontsUsed,
bookWeightWord,
isBookFontId,
nearestBookWeight,
withRealWeight,
} from './book-fonts';
import { BOOK_FONT_FACES, BOOK_FONTS_IDS, BOOK_FONTS_PRECACHED, BOOK_WEIGHTS } from './book-fonts.data';
import { bookFontIdSchema, bookWeightSchema } from './book-op-enums';
import { applyBookOps } from './book-ops';
import { blankBook } from './book-pages';
import {
bookStatsElementSchema,
bookTextElementSchema,
type BookDocument,
type BookElement,
type BookSpread,
} from './book.schema';
import { describeSpread } from './describe-spread';
import { describe, expect, it } from 'vitest';
// BKFONT-001 to BKFONT-010
/**
* The book's typefaces as one catalogue: the schema's ids, the weights and
* italics each family really ships, what each is for, and the snap the
* Studio and the page operations share, so the assistant never sets a weight
* the editor would not.
*/
const frame = { x: 230, y: 40, w: 120, h: 30 };
const text = (id: string, over: Record<string, unknown> = {}) =>
bookTextElementSchema.parse({ id, kind: 'text', frame, text: 'Lisbon', ...over }) as BookElement;
function book(elements: BookElement[]): BookDocument {
const doc = blankBook({ title: 'Lisbon', newId: (p) => `${p}-x` });
const ids = ['sp-cover', 'sp-first', 'sp-in', 'sp-last', 'sp-back'];
const spreads = doc.spreads.map((s, i) => ({ ...s, id: ids[i] as string }));
const inner = { ...(spreads[2] as BookSpread), role: 'inner' as const, elements };
return { ...doc, spreads: [spreads[0], spreads[1], inner, spreads[3], spreads[4]] as BookSpread[] };
}
let seq = 0;
const run = (doc: BookDocument, ops: Parameters<typeof applyBookOps>[1]['ops']) =>
applyBookOps(doc, { spreadId: 'sp-in', ops }, { newId: (p) => `${p}-n${++seq}` });
const inner = (doc: BookDocument) => doc.spreads[2] as BookSpread;
describe('the catalogue', () => {
it('BKFONT-001: names every family the schema takes, once, with a face, a stack ending in a generic and a weight', () => {
expect([...BOOK_FONTS_IDS].sort()).toEqual(Object.keys(BOOK_FONT_FACES).sort());
expect(new Set(BOOK_FONTS_IDS).size).toBe(BOOK_FONTS_IDS.length);
expect(BOOK_FONTS_IDS.slice(0, 7)).toEqual(['sans', 'inter', 'serif', 'garamond', 'playfair', 'display', 'bebas']);
for (const id of BOOK_FONTS_IDS) {
const face = BOOK_FONT_FACES[id];
expect(face.stack.startsWith(`"${face.name}"`), id).toBe(true);
expect(['sans-serif', 'serif', 'cursive', 'monospace'], id).toContain(face.stack.split(',').pop()?.trim());
expect(face.weights.length, id).toBeGreaterThan(0);
expect(
[...face.weights].sort((a, b) => a - b),
id,
).toEqual([...face.weights]);
for (const w of face.italics) expect(face.weights, `${id} italic ${w}`).toContain(w);
expect(face.letterEm, id).toBeGreaterThan(0.2);
expect(face.letterEm, id).toBeLessThan(0.7);
expect(face.roles.length, id).toBeGreaterThan(0);
}
});
it('BKFONT-002: has real italics for the serifs that used to be slanted by the browser', () => {
for (const id of ['serif', 'garamond', 'playfair', 'cormorant']) {
expect(bookFontHasItalic(id, 400), id).toBe(true);
}
expect(bookFontHasItalic('sans', 400)).toBe(false);
expect(bookFontHasItalic('playfair', 300)).toBe(false);
expect(bookFontHasItalic('nothing', 400)).toBe(false);
});
it('BKFONT-003: installs offline only the faces the book shipped with, each a weight the family has', () => {
for (const [id, weights] of Object.entries(BOOK_FONTS_PRECACHED)) {
for (const w of weights ?? []) expect(bookFontHasWeight(id, w), `${id} ${w}`).toBe(true);
}
expect(Object.keys(BOOK_FONTS_PRECACHED).sort()).toEqual(BOOK_FONTS_IDS.slice(0, 7).sort());
});
it('BKFONT-004: reads a family and its roles, and answers an unknown id without guessing', () => {
expect(bookFontFace('anton')?.name).toBe('Anton');
expect(bookFontFace('toString')).toBeUndefined();
expect([isBookFontId('caveat'), isBookFontId('comic')]).toEqual([true, false]);
expect([bookFontRole('caveat'), bookFontRole('mono'), bookFontRole('anton'), bookFontRole('nothing')]).toEqual([
'script',
'mono',
'display',
'body',
]);
expect([bookFontSuits('garamond', 'body'), bookFontSuits('anton', 'body')]).toEqual([true, false]);
expect([bookFontCountsInMix('caveat'), bookFontCountsInMix('mono'), bookFontCountsInMix('serif')]).toEqual([
false,
false,
true,
]);
});
});
describe('weights', () => {
it('BKFONT-005: snaps to the nearest weight a family has, the lighter on a tie, and leaves an unknown family alone', () => {
expect(nearestBookWeight('anton', 900)).toBe(400);
expect(nearestBookWeight('oswald', 100)).toBe(200);
expect(nearestBookWeight('oswald', 900)).toBe(700);
expect(nearestBookWeight('mono', 700)).toBe(500);
expect(nearestBookWeight('cormorant', 200)).toBe(300);
expect(nearestBookWeight('sans', 900)).toBe(900);
expect(nearestBookWeight('unknown', 600)).toBe(600);
expect([bookFontHasWeight('bebas', 700), bookFontHasWeight('unknown', 650)]).toEqual([false, true]);
expect(BOOK_WEIGHTS.map(bookWeightWord)).toEqual([
'thin',
'extralight',
'light',
'regular',
'medium',
'semibold',
'bold',
'extrabold',
'black',
]);
expect(bookWeightWord(450)).toBe('450');
});
it('BKFONT-006: moves a typeset element onto a real weight and hands every other element back as it was', () => {
const heavy = text('t', { font: 'anton', weight: 700 });
expect(withRealWeight(heavy)).toMatchObject({ weight: 400 });
const fine = text('t', { font: 'sans', weight: 700 });
expect(withRealWeight(fine)).toBe(fine);
const stats = bookStatsElementSchema.parse({ id: 's', kind: 'stats', frame, font: 'league' }) as BookElement;
expect(withRealWeight(stats)).toMatchObject({ weight: 400 });
const shape = { id: 'x', kind: 'shape', frame } as unknown as BookElement;
expect(withRealWeight(shape)).toBe(shape);
});
it('BKFONT-007: takes every weight from 100 to 900 in a document and nothing between them', () => {
expect(bookTextElementSchema.safeParse({ id: 't', kind: 'text', frame, weight: 100 }).success).toBe(true);
expect(bookTextElementSchema.safeParse({ id: 't', kind: 'text', frame, weight: 900 }).success).toBe(true);
expect(bookTextElementSchema.safeParse({ id: 't', kind: 'text', frame, weight: 450 }).success).toBe(false);
expect(bookWeightSchema.safeParse(800).success).toBe(true);
});
});
describe('a book and its faces', () => {
it('BKFONT-008: lists the families a book is set in and the faces it needs drawn, each once', () => {
const doc = book([
text('t1', { font: 'caveat', weight: 700 }),
text('t2', { font: 'serif', italic: true }),
text('t3', { font: 'sans', italic: true }),
text('t4', { font: 'anton', text: ' ' }),
bookStatsElementSchema.parse({ id: 's', kind: 'stats', frame, font: 'oswald' }) as BookElement,
]);
const used = bookFontsUsed(doc);
expect(used).toEqual(expect.arrayContaining(['caveat', 'serif', 'oswald']));
expect(used).not.toContain('anton');
expect(used.indexOf('serif')).toBeLessThan(used.indexOf('caveat'));
const faces = bookFacesUsed(doc);
expect(faces).toContainEqual({ font: 'caveat', weight: 700, italic: false });
expect(faces).toContainEqual({ font: 'serif', weight: 400, italic: true });
// Poppins has no italic file: its slant is the browser's, so the upright face is what loads.
expect(faces).toContainEqual({ font: 'sans', weight: 400, italic: false });
expect(faces).toContainEqual({ font: 'oswald', weight: 700, italic: false });
expect(faces).toContainEqual({ font: 'oswald', weight: 600, italic: false });
const keys = faces.map((f) => `${f.font}|${f.weight}|${f.italic}`);
expect(new Set(keys).size).toBe(keys.length);
});
});
describe('the assistant reads the same type', () => {
it('BKFONT-009: describes every family with its role and weights in the id the tools take', () => {
const index = bookFontIndex();
for (const id of BOOK_FONTS_IDS) expect(index, id).toContain(id);
expect(index).toMatch(/^display: /);
expect(index).toContain('script: caveat 400-700');
expect(index).toContain('bebas 400 caps');
expect(index).toContain('serif Lora 400-700 i');
expect(index).not.toMatch(/[\u2013\u2014\n]/);
expect(String(bookFontIdSchema.description)).toContain(index);
});
it('BKFONT-010: sets a style or a text change at a weight the family has, and says so in the page description', () => {
const doc = book([text('t1')]);
const styled = run(doc, [{ op: 'style', elementId: 't1', set: { kind: 'text', font: 'anton', weight: 900 } }]);
if (!styled.ok) throw new Error(styled.error.message);
expect(inner(styled.doc).elements[0]).toMatchObject({ font: 'anton', weight: 400 });
const retexted = run(doc, [{ op: 'text', elementId: 't1', font: 'mono', weight: 700 }]);
if (!retexted.ok) throw new Error(retexted.error.message);
expect(inner(retexted.doc).elements[0]).toMatchObject({ font: 'mono', weight: 500 });
const page = describeSpread(retexted.doc, 'sp-in')?.text ?? '';
expect(page).toContain('mono (mono) 11 pt medium');
const light = run(doc, [{ op: 'style', elementId: 't1', set: { kind: 'text', font: 'fraunces', weight: 300 } }]);
if (!light.ok) throw new Error(light.error.message);
expect(describeSpread(light.doc, 'sp-in')?.text).toContain('fraunces (display) 11 pt light');
});
});
+188
View File
@@ -0,0 +1,188 @@
import {
BOOK_FONT_CATEGORIES,
BOOK_FONT_FACES,
BOOK_FONT_ROLES,
BOOK_FONTS_IDS,
BOOK_FONTS_PRECACHED,
BOOK_WEIGHTS,
type BookFontCategory,
type BookFontFace,
type BookFontFamily,
type BookFontRole,
type BookWeight,
} from './book-fonts.data';
import type { BookDocument, BookElement } from './book.schema';
/**
* What the book's typefaces can do, read off the catalogue in
* book-fonts.data.ts: which weights and italics a family really has, what it
* is for, and which faces a book uses. Shared by the Studio, the findings,
* the design engine and the page operations, so the assistant and the editor
* snap a weight the same way.
*/
export {
BOOK_FONT_CATEGORIES,
BOOK_FONT_FACES,
BOOK_FONT_ROLES,
BOOK_FONTS_PRECACHED,
BOOK_WEIGHTS,
type BookFontCategory,
type BookFontFace,
type BookFontRole,
type BookWeight,
};
/** The family behind an id, or undefined for an id this build does not know. */
export function bookFontFace(id: string): BookFontFace | undefined {
return Object.prototype.hasOwnProperty.call(BOOK_FONT_FACES, id) ? BOOK_FONT_FACES[id as BookFontFamily] : undefined;
}
/** Whether `id` is a family of the catalogue. */
export const isBookFontId = (id: string): id is BookFontFamily => bookFontFace(id) !== undefined;
/** Whether a family ships the weight. An unknown family is not second-guessed. */
export function bookFontHasWeight(id: string, weight: number): boolean {
const face = bookFontFace(id);
return face ? (face.weights as readonly number[]).includes(weight) : true;
}
/**
* The nearest weight a family really has, the lighter one on a tie.
*
* A synthesised bold is a smeared regular in print, so a family with one
* weight gets that weight rather than a request the renderer has to fake. An
* unknown family keeps what was asked.
*/
export function nearestBookWeight(id: string, weight: number): number {
const face = bookFontFace(id);
if (!face || (face.weights as readonly number[]).includes(weight)) return weight;
return face.weights.reduce<number>(
(best, w) => (Math.abs(w - weight) < Math.abs(best - weight) ? w : best),
face.weights[0] ?? weight,
);
}
/**
* The lighter weight a travel element sets beside its figures (a unit after
* a number, a caption under it): a step under the weight, never under 400
* unless the weight itself is, and one the family has.
*/
export function bookLighterWeight(id: string, weight: number): number {
return nearestBookWeight(id, Math.min(weight, Math.max(400, weight - 100)));
}
/** Whether a family has a real italic at that weight, rather than the browser's slant. */
export function bookFontHasItalic(id: string, weight: number): boolean {
const face = bookFontFace(id);
return !!face && (face.italics as readonly number[]).includes(weight);
}
/** What a family is for: the first of its roles, `body` for an unknown one. */
export const bookFontRole = (id: string): BookFontRole => bookFontFace(id)?.roles[0] ?? 'body';
/** Whether a family suits a role at all, not only as its first. */
export function bookFontSuits(id: string, role: BookFontRole): boolean {
return bookFontFace(id)?.roles.includes(role) ?? false;
}
/**
* Whether a family counts towards the faces a book is set in. A script laid
* over a title and a mono line on a ticket are ornaments, not a third voice.
*/
export function bookFontCountsInMix(id: string): boolean {
const role = bookFontRole(id);
return role !== 'script' && role !== 'mono';
}
/** The element with its weight moved onto one its family has, when it is set in type at all. */
export function withRealWeight<T extends BookElement>(el: T): T {
if (!('font' in el) || !('weight' in el)) return el;
const weight = nearestBookWeight(el.font, el.weight);
return weight === el.weight ? el : { ...el, weight: weight as BookWeight };
}
/** One face a book is set in: the family, the weight the renderer draws and the slant. */
export interface BookFaceUse {
font: BookFontFamily;
weight: number;
italic: boolean;
}
/** The type-setting elements on a book's pages: everything that names a font, an empty text aside. */
function typeset(doc: BookDocument): Extract<BookElement, { font: string }>[] {
const out: Extract<BookElement, { font: string }>[] = [];
for (const spread of doc.spreads) {
for (const el of spread.elements) {
if ('font' in el && (el.kind !== 'text' || el.text.trim())) out.push(el);
}
}
return out;
}
/** The families a book is set in, page numbers included when shown, in catalogue order. */
export function bookFontsUsed(doc: BookDocument): BookFontFamily[] {
const used = new Set<string>(typeset(doc).map((el) => el.font));
if (doc.page.pageNumbers.show) used.add(doc.page.pageNumbers.font);
return BOOK_FONTS_IDS.filter((id) => used.has(id));
}
/**
* Every face a book needs drawn, each once: what the Studio warms into the
* offline cache when a book opens, so the book still reads in its own type
* without a connection. Travel elements set a lighter weight beside their
* figures, which is why both of those are named.
*/
export function bookFacesUsed(doc: BookDocument): BookFaceUse[] {
const seen = new Map<string, BookFaceUse>();
const add = (font: BookFontFamily, weight: number, italic: boolean) => {
const use = { font, weight: nearestBookWeight(font, weight), italic: italic && bookFontHasItalic(font, weight) };
seen.set(`${use.font}|${use.weight}|${use.italic}`, use);
};
for (const el of typeset(doc)) {
add(el.font, el.weight, el.kind === 'text' && el.italic);
if (el.kind !== 'text') add(el.font, bookLighterWeight(el.font, el.weight), false);
}
if (doc.page.pageNumbers.show) add(doc.page.pageNumbers.font, 500, false);
return [...seen.values()];
}
/** A weight as the assistant reads it, the CSS name beside the number. */
const WEIGHT_WORDS: Record<BookWeight, string> = {
100: 'thin',
200: 'extralight',
300: 'light',
400: 'regular',
500: 'medium',
600: 'semibold',
700: 'bold',
800: 'extrabold',
900: 'black',
};
export const bookWeightWord = (weight: number): string => WEIGHT_WORDS[weight as BookWeight] ?? String(weight);
/**
* The catalogue as one line per role, for the font id's description in the
* tool schemas: an id, its face and the weights it has, an i for a real
* italic, so a model picks a script for an overlay and never asks Anton for
* bold.
*/
export function bookFontIndex(): string {
const span = (ws: readonly number[]) => (ws.length === 1 ? `${ws[0]}` : `${ws[0]}-${ws[ws.length - 1]}`);
return BOOK_FONT_ROLES.map((role) => {
const ids = BOOK_FONTS_IDS.filter((id) => bookFontRole(id) === role);
if (!ids.length) return '';
const listed = ids.map((id) => {
const face = BOOK_FONT_FACES[id];
const italic = face.italics.length ? ' i' : '';
const caps = 'caps' in face ? ' caps' : '';
// The face is named only where the id does not already say it, for the size of the tool schemas.
const named = face.name.toLowerCase().startsWith(id) ? '' : ` ${face.name}`;
return `${id}${named} ${span(face.weights)}${italic}${caps}`;
});
return `${role}: ${listed.join(', ')}.`;
})
.filter(Boolean)
.join(' ');
}
+11 -4
View File
@@ -1,3 +1,4 @@
import { bookFontIndex } from './book-fonts';
import { BOOK_ICON_NAMES } from './book-icons';
import {
BOOK_BADGES,
@@ -37,14 +38,20 @@ const text = bookTextElementSchema.shape;
const shape = bookShapeElementSchema.shape;
const map = bookMapElementSchema.shape;
/** The book's typefaces, with the face each id sets. */
/**
* The book's typefaces by what they are for, each with its face, the weights
* it has and an i where it has a real italic. A weight a family lacks is set
* at its nearest one, as in the Studio.
*/
export const bookFontIdSchema = z
.enum(BOOK_FONTS_IDS)
.describe(
'Typeface: sans Poppins, inter Inter, serif Lora, garamond EB Garamond, playfair Playfair Display, display MuseoModerno, bebas Bebas Neue (caps, one weight).',
);
.describe(`Typeface by role; weights a family lacks snap to its nearest. ${bookFontIndex()}`);
/** The typeface of a travel element, where the full line above would repeat ten times. */
export const bookTypesetFontSchema = z.enum(BOOK_FONTS_IDS).describe('Typeface id, as for text.');
/** How heavy type is set, 100 thin to 900 black. */
export const bookWeightSchema = bookTextElementSchema.shape.weight
.unwrap()
.describe('Weight 100 thin to 900 black, 400 regular, 700 bold; snapped to one the family has.');
/** Every shape the Studio draws, and so every mask a photo can be cut to. */
export const bookShapeIdSchema = z
+7 -2
View File
@@ -1,6 +1,7 @@
import { relayoutSpread } from './auto/auto-book';
import type { AutoEntry, AutoInput } from './auto/auto-types';
import { bookElementPropsSchema, type BOOK_ELEMENT_OP_SCHEMAS, type BookNewElement } from './book-element-ops.schema';
import { withRealWeight } from './book-fonts';
import { bookGridElements, defaultBookGridBox, findBookGrid } from './book-grids';
import {
bookBoundWords,
@@ -138,7 +139,11 @@ function lookKeys(shape: object): string[] {
return Object.keys(shape).filter((k) => k !== 'kind');
}
/** The element with the given look fields set; millimetre fields rounded as the document stores them. */
/**
* The element with the given look fields set; millimetre fields rounded as the
* document stores them, and the weight moved onto one its family has, as the
* Studio's weight control does.
*/
function withProps(el: BookElement, props: Record<string, unknown>): BookElement {
const keys = PROP_KEYS.get(el.kind) ?? [];
const next: Record<string, unknown> = { ...el };
@@ -147,7 +152,7 @@ function withProps(el: BookElement, props: Record<string, unknown>): BookElement
if (value === undefined) continue;
next[key] = (key === 'radius' || key === 'strokeWidth') && typeof value === 'number' ? round2(value) : value;
}
return next as unknown as BookElement;
return withRealWeight(next as unknown as BookElement);
}
// ── add ────────────────────────────────────────────────────────────────────
+5 -2
View File
@@ -1,3 +1,4 @@
import { withRealWeight } from './book-fonts';
import { bookGridElements, defaultBookGridBox, findBookGrid, type BookGridDef } from './book-grids';
import { bookElementId } from './book-ids';
import { BOOK_TEXT_STYLES, applyBookTemplate, classifyBookTexts, findBookTemplate } from './book-layouts';
@@ -413,9 +414,11 @@ function opText(spread: BookSpread, op: Op<'text'>, env: Env): BookSpread {
op.elementId !== undefined
? textEl(spread, op.elementId, env)
: classifyBookTexts(roleTexts(spread))[op.role ?? 'body'];
if (existing && existing.kind === 'text') return replaceEl(spread, retext(unlocked(existing, env), op, env));
// The weight lands on one the family has, whichever of the two a change names.
if (existing && existing.kind === 'text')
return replaceEl(spread, withRealWeight(retext(unlocked(existing, env), op, env)));
withRoom(spread, 1, env);
return { ...spread, elements: [...spread.elements, newText(spread, op, env)] };
return { ...spread, elements: [...spread.elements, withRealWeight(newText(spread, op, env))] };
}
function retext(el: BookTextElement, op: Op<'text'>, env: Env): BookTextElement {
+16 -11
View File
@@ -21,6 +21,9 @@ import { describe, expect, it } from 'vitest';
const frame = { x: 12, y: 18, w: 60, h: 40 };
/** A revision one past what this build reads. */
const NEWER = BOOK_SCHEMA_REV + 1;
const doc = (over: Record<string, unknown> = {}) => ({
version: 1,
title: 'Lisbon',
@@ -40,7 +43,8 @@ const doc = (over: Record<string, unknown> = {}) => ({
describe('the revision a document was written by', () => {
it('BKREV-001: reads 1 for every document from before the field, and for anything that is not a whole number', () => {
expect(BOOK_SCHEMA_REV).toBe(1);
// Raised for the first time by the wider type: new families and weights an older reader drops.
expect(BOOK_SCHEMA_REV).toBeGreaterThanOrEqual(2);
expect(bookSchemaRev(doc())).toBe(1);
expect(bookSchemaRev(null)).toBe(1);
expect(bookSchemaRev('{"schemaRev":4}')).toBe(1);
@@ -56,33 +60,34 @@ describe('the revision a document was written by', () => {
expect(odd.success && odd.data.spreads[0]?.elements).toHaveLength(1);
});
it('BKREV-003: leaves a book saved today as it was, with no revision written into it', () => {
it('BKREV-003: a writer at revision 1 leaves a book as it was, with no revision written into it', () => {
const today = normalizeBookDocument(doc());
expect('schemaRev' in today).toBe(false);
expect(stampBookSchemaRev(today)).toBe(today);
expect(JSON.stringify(stampBookSchemaRev(today))).toBe(JSON.stringify(today));
expect(stampBookSchemaRev(today, 1)).toBe(today);
expect(JSON.stringify(stampBookSchemaRev(today, 1))).toBe(JSON.stringify(today));
expect(stampBookSchemaRev(today).schemaRev).toBe(BOOK_SCHEMA_REV);
});
});
describe('a reader older than the document', () => {
it('BKREV-004: knows the document is newer than it, read from the raw value even when the rest will not parse', () => {
expect(isBookNewerThanReader(doc({ schemaRev: 2 }))).toBe(true);
expect(isBookNewerThanReader(doc({ schemaRev: NEWER }))).toBe(true);
expect(isBookNewerThanReader(doc({ schemaRev: 1 }))).toBe(false);
expect(isBookNewerThanReader(doc())).toBe(false);
// A reader of a later revision reads the same document as its own.
expect(isBookNewerThanReader(doc({ schemaRev: 2 }), 2)).toBe(false);
const unreadable = doc({ schemaRev: 5, page: 'not a page' });
expect(isBookNewerThanReader(doc({ schemaRev: NEWER }), NEWER)).toBe(false);
const unreadable = doc({ schemaRev: NEWER + 3, page: 'not a page' });
expect(isBookNewerThanReader(unreadable)).toBe(true);
});
it('BKREV-005: reads a newer book as far as it can and says so, so nothing it dropped is saved', () => {
const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' };
const raw = doc({ schemaRev: 2 });
const raw = doc({ schemaRev: NEWER });
const spread = (raw.spreads as { elements: unknown[] }[])[0];
if (spread) spread.elements.push(sticker);
const read = readBookDocument(raw);
expect(read.newer).toBe(true);
expect(read.document.schemaRev).toBe(2);
expect(read.document.schemaRev).toBe(NEWER);
expect(read.document.spreads[0]?.elements.map((e) => e.id)).toEqual(['t1']);
expect(readBookDocument(doc()).newer).toBe(false);
});
@@ -99,10 +104,10 @@ describe('a reader older than the document', () => {
describe('a save request carrying a newer book', () => {
it('BKREV-007: reaches the server as its revision alone, so the save is refused there rather than at the pipe', () => {
const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' };
const raw = doc({ schemaRev: 2 });
const raw = doc({ schemaRev: NEWER });
const spread = (raw.spreads as { elements: unknown[] }[])[0];
if (spread) spread.elements.push(sticker);
expect(bookSaveRequestSchema.parse({ title: 'T', document: raw }).document).toEqual({ schemaRev: 2 });
expect(bookSaveRequestSchema.parse({ title: 'T', document: raw }).document).toEqual({ schemaRev: NEWER });
// A book of today still reads whole, revision or none.
const today = bookSaveRequestSchema.parse({ title: 'T', document: doc() }).document;
expect(today).toEqual(bookDocumentSchema.parse(doc()));
+1 -1
View File
@@ -28,7 +28,7 @@ import type { BookDocument } from './book.schema';
* saves only once that is above 1, so a book saved today stores exactly what
* it stored before this existed.
*/
export const BOOK_SCHEMA_REV = 1;
export const BOOK_SCHEMA_REV = 2;
/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
export const BOOK_SCHEMA_NEWER_CODE = 'book_schema_newer';
+10 -8
View File
@@ -1,3 +1,5 @@
import { BOOK_FONTS_IDS, BOOK_WEIGHTS, type BookFontFamily } from './book-fonts.data';
import { z } from 'zod';
/**
@@ -157,15 +159,15 @@ export const BOOK_SHAPES = [
export type BookShapeId = (typeof BOOK_SHAPES)[number];
/**
* The families a book may be set in.
* The families a book may be set in, and the weights type is set at. The
* catalogue is book-fonts.data.ts: which face each id names, its weights, its
* real italics and what it is for.
*
* `sans`, `serif` and `display` are the three the format started with and are
* kept by name, so every document written so far still parses — they now point
* at bundled faces rather than at whatever the rendering machine happened to
* have installed. See client/src/components/Studio/bookFonts.ts.
* kept by name, so every document written so far still parses.
*/
export const BOOK_FONTS_IDS = ['sans', 'inter', 'serif', 'garamond', 'playfair', 'display', 'bebas'] as const;
export type BookFontFamily = (typeof BOOK_FONTS_IDS)[number];
export { BOOK_FONTS_IDS, type BookFontFamily };
const bookWeightSchema = z.literal(BOOK_WEIGHTS);
export const bookFrameSchema = z.object({
/** Millimetres from the left edge of the spread. Negative means bleed. */
@@ -230,7 +232,7 @@ export const bookTextElementSchema = z.object({
font: z.enum(BOOK_FONTS_IDS).default('sans'),
/** Points. */
size: z.number().min(4).max(200).default(11),
weight: z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).default(400),
weight: bookWeightSchema.default(400),
italic: z.boolean().default(false),
align: z.enum(['left', 'center', 'right', 'justify']).default('left'),
/** Multiple of the font size. */
@@ -362,7 +364,7 @@ const typeset = {
* together, the way `textScale` moves their size. Without it the only way to
* lighten a stats block was to not use one.
*/
weight: z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).default(700),
weight: bookWeightSchema.default(700),
/** Set when the journey has moved on since these values were taken. */
stale: z.boolean().default(false),
};
+5 -2
View File
@@ -1,5 +1,6 @@
import { photoDpi, spreadFindings } from './book-findings';
import { designFindings, sortBookFindings } from './book-findings-design';
import { bookFontRole, bookWeightWord, nearestBookWeight } from './book-fonts';
import { BOOK_GRIDS } from './book-grids';
import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES, type BookTemplate } from './book-layouts';
import type { BookPhotoInfo } from './book-ops-core';
@@ -85,7 +86,9 @@ function elementWords(el: BookElement, photos: readonly BookPhotoInfo[] | undefi
photos?.find((p) => p.photoId === el.photoId),
);
case 'text': {
const style = `${el.font} ${n(el.size)} pt${el.weight >= 600 ? ' bold' : ''}${el.italic ? ' italic' : ''}, ${el.align}, ${el.color}`;
const weight = nearestBookWeight(el.font, el.weight);
const set = `${el.font} (${bookFontRole(el.font)}) ${n(el.size)} pt${weight === 400 ? '' : ` ${bookWeightWord(weight)}`}`;
const style = `${set}${el.italic ? ' italic' : ''}, ${el.align}, ${el.color}`;
const bound = el.binding ? `, bound to ${el.binding.source}${el.overridden ? ' (overridden)' : ''}` : '';
return `${style}: "${head(el.text, 80)}"${bound}`;
}
@@ -110,7 +113,7 @@ type TravelElement = Extract<BookElement, { kind: 'map' | 'stats' | 'countries'
/** What a travel element shows, and how it is set: its face, ink, accent and size. */
function travelWords(el: TravelElement): string {
const set = `${el.font} ${el.weight >= 600 ? 'bold' : 'regular'}, ink ${el.color}, accent ${el.accent}, type x${n(el.textScale)}`;
const set = `${el.font} ${bookWeightWord(nearestBookWeight(el.font, el.weight))}, ink ${el.color}, accent ${el.accent}, type x${n(el.textScale)}`;
const stale = el.stale ? '; its figures are out of date with the journey' : '';
return `${travelWhat(el)}; ${set}${stale}`;
}
+4 -1
View File
@@ -1,6 +1,7 @@
import { buildBook, distributeGallery } from '../auto/auto-book';
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
import { bookLevelFindings, sortBookFindings } from '../book-findings-design';
import { withRealWeight } from '../book-fonts';
import { bookElementId } from '../book-ids';
import type { BookPhotoInfo } from '../book-ops-core';
import type { BookFinding } from '../book-ops.schema';
@@ -252,7 +253,9 @@ export function designBook(
const repairCtx = { page, photos: input.photos, look, newId };
const designed = [...framing, ...inner.spreads, ...closing].map((d): DesignedSpread => {
const fixed = repairSpread(d.spread, repairCtx);
return { ...d, spread: fixed.spread, findings: fixed.findings };
// Every face at a weight its family has: a travel element's bold in a one-weight poster face would be faked.
const spread = { ...fixed.spread, elements: fixed.spread.elements.map(withRealWeight) };
return { ...d, spread, findings: fixed.findings };
});
const merged = mergeDesign(doc, designed, directives.keep);
+6 -3
View File
@@ -1,5 +1,6 @@
import type { AutoEntry, AutoPhoto } from '../auto/auto-types';
import { DESIGN_TEXT_HEADROOM, MIN_PRINT_DPI, textHeightNeeded } from '../book-findings';
import { nearestBookWeight } from '../book-fonts';
import type { BookPhotoInfo } from '../book-ops-core';
import { formatBookDate } from '../book-text';
import { bookBadgeValue, bookTravelBadge, type BookTravelContext } from '../book-travel';
@@ -158,6 +159,7 @@ type TextAt = Pick<BookFrame, 'x' | 'y' | 'w'>;
/** A text in the book's look, its frame as tall as its words need. */
export function textEl(c: ComposeCtx, at: TextAt, words: string, role: TextRole, opts: TextOpts = {}): BookTextElement {
const defaultColour = role === 'caption' || role === 'meta' ? c.look.muted : c.look.ink;
const font = role === 'heading' || role === 'sub' ? c.look.heading : c.look.body;
const el: BookTextElement = {
id: c.newId('t'),
kind: 'text',
@@ -166,9 +168,10 @@ export function textEl(c: ComposeCtx, at: TextAt, words: string, role: TextRole,
opacity: 1,
locked: false,
text: words,
font: role === 'heading' || role === 'sub' ? c.look.heading : c.look.body,
font,
size: opts.size ?? c.look.sizes[role],
weight: WEIGHT[role],
// A family that lacks the role's weight is set at its nearest, never at a synthesised one.
weight: nearestBookWeight(font, WEIGHT[role]) as BookTextElement['weight'],
italic: false,
align: opts.align ?? 'left',
leading: LEADING[role],
@@ -258,7 +261,7 @@ export function titleBlock(
if (value) {
const chip = frameOf(box.x, y, scaled(c, 24), scaled(c, 7));
const badge = bookTravelBadge({ id: c.newId('bd'), frame: chip, variant: 'day', value });
els.push({ ...badge, font: c.look.heading, accent: c.look.accent });
els.push({ ...badge, font: c.look.figures, accent: c.look.accent });
y += chip.h + 4;
}
}
+3 -3
View File
@@ -161,7 +161,7 @@ export function composeSummary(
const textSide = mapSide === 'left' ? 'right' : 'left';
const els: BookElement[] = [];
const map = bookTravelMap(c.travel, stats, { id: c.newId('mp'), frame: liveBox(c, mapSide), scope: 'journey' });
els.push({ ...map, style: c.look.mapStyle, font: c.look.heading, color: c.look.ink, accent: c.look.accent });
els.push({ ...map, style: c.look.mapStyle, font: c.look.figures, color: c.look.ink, accent: c.look.accent });
const head = label(c, words, textSide);
els.push(...head.els);
const metrics = metricsOf(c, stats);
@@ -169,7 +169,7 @@ export function composeSummary(
const box = liveBox(c, textSide);
const frame = frameOf(box.x, head.bottom, box.w, Math.min(box.h * 0.6, box.y + box.h - head.bottom));
const figures = bookTravelStats(stats, { id: c.newId('st'), frame, metrics });
els.push({ ...figures, font: c.look.heading, color: c.look.ink, accent: c.look.accent });
els.push({ ...figures, font: c.look.figures, color: c.look.ink, accent: c.look.accent });
}
return els;
}
@@ -179,7 +179,7 @@ export function composeCountries(c: ComposeCtx, words: string): BookElement[] {
const head = label(c, words, 'left');
const box = liveBox(c, 'right');
const countries = bookTravelCountries(c.travel, { id: c.newId('co'), frame: box });
return [...head.els, { ...countries, font: c.look.heading, color: c.look.ink, accent: c.look.accent }];
return [...head.els, { ...countries, font: c.look.figures, color: c.look.ink, accent: c.look.accent }];
}
/**
+2 -2
View File
@@ -146,7 +146,7 @@ function listUnder(c: ComposeCtx, entry: AutoEntry, foot: Foot): BookElement | n
if (foot.top + h > foot.bottom) return null;
const labels = { pros: c.travel.labels.pros, cons: c.travel.labels.cons };
const list = bookTravelList(labels, { id: c.newId('li'), frame: frameOf(foot.x, foot.top, foot.w, h), items });
return { ...list, font: c.look.heading, color: c.look.ink, accent: c.look.accent };
return { ...list, font: c.look.figures, color: c.look.ink, accent: c.look.accent };
}
/**
@@ -293,7 +293,7 @@ function chapterMap(c: ComposeCtx, entry: AutoEntry, box: BookFrame): BookElemen
const h = box.h * 0.3;
const frame = frameOf(box.x, box.y + box.h - h, box.w, h);
const map = bookTravelMap(c.travel, stats, { id: c.newId('mp'), frame, scope: { tripId } });
const look = { style: c.look.mapStyle, font: c.look.heading, color: c.look.ink, accent: c.look.accent };
const look = { style: c.look.mapStyle, font: c.look.figures, color: c.look.ink, accent: c.look.accent };
return { ...map, ...look, pinStyle: 'dot' };
}
+28 -1
View File
@@ -1,3 +1,4 @@
import { bookFontHasWeight } from '../book-fonts';
import { bookPageSetupSchema, type BookDocument, type BookTextElement } from '../book.schema';
import { designBook, type BookDesignInput } from './design-book';
import { bookDesignDirectivesSchema, type BookDesignDirectivesInput } from './design-directives.schema';
@@ -7,7 +8,7 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
// BOOKSTY-001 to BOOKSTY-004
// BOOKSTY-001 to BOOKSTY-007
/**
* The type scale of a designed book: `type.scale` reaches a poster's sizes,
@@ -78,3 +79,29 @@ describe('the cover title', () => {
expect((big?.frame.y ?? 0) + (big?.frame.h ?? 0)).toBeLessThanOrEqual(page.pageHeight);
});
});
describe('the typefaces by their roles', () => {
it('BOOKSTY-005: keeps a body face that sets running text, and falls back to the style for one that does not', () => {
expect(lookOf({ style: 'classic', type: { body: 'garamond' } }).body).toBe('garamond');
// A poster face and a script are a title's, never a story's.
expect(lookOf({ style: 'classic', type: { body: 'anton' } }).body).toBe('sans');
expect(lookOf({ style: 'editorial', type: { body: 'caveat' } }).body).toBe('garamond');
});
it('BOOKSTY-006: sets the travel elements in the heading face, unless that is a script or a mono face', () => {
expect(lookOf({ style: 'bold' }).figures).toBe('bebas');
const script = lookOf({ style: 'journal', type: { heading: 'caveat' } });
expect([script.heading, script.figures]).toEqual(['caveat', 'garamond']);
expect(lookOf({ style: 'classic', type: { heading: 'mono', body: 'inter' } }).figures).toBe('inter');
});
it('BOOKSTY-007: sets every text and travel element at a weight its family really has', () => {
const { doc } = designBook(null, rich(), { style: 'bold', type: { heading: 'anton' } });
const typeset = doc.spreads.flatMap((s) => s.elements.filter((e) => 'weight' in e && 'font' in e));
expect(typeset.length).toBeGreaterThan(0);
for (const el of typeset) {
if ('weight' in el && 'font' in el) expect(bookFontHasWeight(el.font, el.weight), el.id).toBe(true);
}
expect(typeset.some((e) => 'font' in e && e.font === 'anton')).toBe(true);
});
});

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