Compare commits

..

36 Commits

Author SHA1 Message Date
Maurice e29aa99269 fix(tours): a lake shortcut costs no more than the stretch of path it replaces
A shortcut was held to the dearer of its two end cells, so an end out on open water lifted the ceiling just as a bank end did before: a leg from a point out on a lake was pulled straight across, through water the search had gone round, for a SUP beyond its reach and for the shore style beyond its band. The same happened midway once a kept vertex lay in water past the boat's reach. A shortcut is now priced along its line on open water and taken only when it costs no more than the stretch of cell path it replaces (with 5 % to spare); the shore distance check stays as it was.

Measured on the WATER-005 lake and a larger one with a bay and a spit: legs from bank to bank are as before within a few metres, except a kayak no longer crosses 409 m of water past its reach that the search avoided; from a point 500 m out a SUP covers 321 m past its reach (300 m cannot be avoided) instead of 1301 m, a canoe 133 m instead of 1132 m, and the shore style follows the bank. WATER-012 holds the case from a point out on the lake.
2026-10-18 22:54:41 +00:00
Maurice 7907963e03 test(tours): the region load's failure paths and Geofabrik's size question are tested
The paddle obstacles domain held 90 % of its branches, under its 92 % floor, because the new region load and size question had their failure paths untested. Now covered: a notice to the admins that fails never undoes the request, a list out of reach with something other than an error, the smallest of several held regions, a request filed meanwhile that is not announced twice, and sizeOf asked with HEAD, unknown without a usable size or answer, and refused for an address the list could not name.
2026-10-18 22:35:33 +00:00
Maurice 45b9f07238 fix(tours): a lake leg along the shore stays along it when it starts at the bank
WATER-005 found the shore style crossing straight over like the direct one. Pulling a cell path straight allowed a shortcut through water as costly as its dearer end, and since the clearance ramp a cell at the bank costs up to five times, so a leg from a river mouth lifted that ceiling above the open water and was pulled across the lake. A shortcut is now held to the cost of the open water alone (the style's band and the boat's reach); how near the bank it may come was already held by the path it replaces. The search costs are unchanged.

Measured on the test lake and a larger one with a bay and a spit: direct, canoe and SUP legs are exactly as before; along the shore the leg no longer crosses (median 98 to 161 m off the bank, was up to 500), and no more samples on land than before.
2026-10-18 22:35:33 +00:00
Maurice d1ec6fb145 test(tours): the inventories count the region requests and the new admin notice
ENT-003 counts 142 entities and CLASSFIELD-002 494 uninitialised NOT NULL scalars with paddle_region_requests (region_id, name, reason) in, and NPREF-005b expects paddle_region_requested among the admin scoped events.
2026-10-18 22:05:44 +00:00
Maurice 961d223c7c test(tours): the paddle obstacles e2e gets the realtime service and the storage paths
The notices to the admins brought NotificationsModule into PaddleObstaclesModule, which needs RealtimeService and opens the storage registry at init. The suite now mounts RealtimeModule with a FakeRealtimeService, as the tours and wanderer suites do, and its DataPathsService stand-in names an uploads and a backups folder in its temp dir.
2026-10-18 22:05:44 +00:00
Maurice faabc533c6 fix(tours): the paddle obstacles module imports DemoModule for the region load
PaddleRegionLoadService injects DemoService, which only the app graph provided through the @Global DemoModule. A TestingModule that pulls PaddleObstaclesModule in through ToursModule (tours.paddle-check, tours.mcp, wanderer e2e) could not resolve it. The module now imports DemoModule itself, as BudgetModule and FeedsModule do.
2026-10-18 22:05:44 +00:00
Maurice bb39745a23 fix(planner): the reorder days dialog opens centred
It was pinned to the top of the window; centred it sits where the other short dialogs open.
2026-10-18 23:39:54 +02:00
Maurice c9e1e8ad86 test(tours): the reasons a region request waits for are pinned to the contract
knip found PaddleRegionRequestsReason exported and imported nowhere. Like the boat, water and lake style columns, the CHECK list of the column (044700) is now held against paddleRegionWaitReasonSchema, so a reason one side knows and the other refuses fails a test instead of an insert.
2026-10-18 21:35:02 +00:00
Maurice 9301053c56 fix(tours): paddle region settings go through the settings service, both values in one transaction
lint:boundaries refused PaddleRegionLoadService injecting the AppSettings repository, which the settings domain owns. SettingsService now reads a registered instance setting (getAppSetting) and writes several of them in one transaction (setAppSettings), and the region load asks it instead.

That also puts the two writes of updateSettings into one transaction, which lint:tx asked for. PLOAD-013 shows that a failed second write leaves the first one unchanged.

load is tagged @txIndependent: it decides one region after the other with Geofabrik asked for a size in between, and a region added or a request filed stands on its own, so no transaction may span it.
2026-10-18 21:35:01 +00:00
Maurice cda6e4bfd1 style(tours): the repo test's busy leg wrapped the way Prettier writes it
The client format gate refused client/src/repo/paddleRouteRepo.test.ts, a new file outside the format baseline.
2026-10-18 21:35:01 +00:00
Maurice dcc1c18fa9 fix(tours): the lint the paddle work grew, back to the baseline
The region load and obstacle helpers move out of their component files (paddleRegionLoad.ts, paddleObstaclePlace.ts), so those files export components only (react-refresh). TourPlannerMapStage takes the take-from-card rule out into paddlePlaceFromCard and works out the cards' end inset once (complexity 24, back under 20). The tour tests reach list items through at() and must() instead of non-null assertions, a position change in the shaping test is voided rather than left floating, and PLOAD-004 seeds its regions with Promise.all.
2026-10-18 21:21:32 +00:00
Maurice 7e9297d867 test(tours): a failed leg in the repo test carries its at, as the contract has it 2026-10-18 21:21:32 +00:00
Maurice a19d67e5a2 fix(tours): lake legs keep off the shore instead of hugging it one cell out
The lake cost only added half again within 12 m of the bank, so the cheapest line round a bend ran one grid cell off the water's edge (7 to 19 m on the big lakes), and pulling it straight could draw it over land. A clearance ramp now makes the water nearer than 80 m (40 m along the shore) steadily dearer, which also centres the route in anything narrower than twice that, the shore style rides a band 100 to 350 m out instead of the bank itself, and a shortcut may not come nearer the shore than the path it replaces. Measured on nine lakes: no sample on land (was up to two), direct keeps 51 to 78 m at its closest (was one cell), along the shore the median is 60 to 100 m (was 30 to 40) and under 1 % of it within 25 m (was up to 22 %); lengths change by under 1 %.
2026-10-18 21:21:32 +00:00
Maurice cd64a2f542 test(tours): type the region load answer in the repo test as the contract's literal 2026-10-18 21:21:32 +00:00
Maurice 1bc82f97fd feat(tours): route paddle tours on the water, shaping points and auto-loaded regions
Paddle tours are planned on the water by TREK's own router: a water network
per paddle region, built from the same Geofabrik extract as the obstacles and
searched in a worker thread of the server (no sidecar). Rivers, canals and
lakes (direct or along the shore), portages round weirs, locks by policy,
whitewater by boat, gaps marked. Found portages and locks become points of
their own; an unplannable leg says why and offers a way out.

- Shaping points: a click on the line sets a point that steers the route
  without being a stop (hikes and paddle tours); recompute a single leg.
- Lake style direct/shore, suggested from the wind forecast.
- Regions load by themselves where a paddler plans (admin setting: automatic,
  on request, off; size cap and a daily count), with requests for the admins,
  a notification, and the load followed on the leg.
- Portage from the last point, portages follow footways via the walking
  router (or stay straight), "put in here" on slipways and put-ins.
- MCP plan_paddle_route with the REST route's rules.
2026-10-18 21:21:32 +00:00
Maurice b76a298bb3 ci(lint): a base version the typed rules cannot lint no longer crashes the report
On the server, linting a file's base text with type-aware rules threw (no program for a text that is not on disk), and the TypeError ended the whole ratchet run. A lint that throws now counts as unknown: the file's current warnings of the rule are told, marked as not compared.
2026-10-18 23:17:33 +02:00
Maurice 17029d364a ci(lint): lintTextClient stays inside the client ratchet
Only check() uses it, as its default; exported it was an unused export to knip, which turned the server quality job on dev red.
2026-10-18 23:16:37 +02:00
Maurice f458278e88 ci(lint): a grown warning count names the file and line that grew it
lint:warnings failed with the total and the files holding the most of the rule, which are old ones the baseline allows; the file that tipped the count was never named, so finding it meant linting by hand. When a rule grows, the ratchet now lints the files that differ from the base twice, as they are and as they were there, and lists every one holding more of the rule, line and message. The base is TREK_LINT_BASE (the CI fetches the pushed-over commit on a push and the target branch on a pull request) or origin/dev; without it the report stays as it was.
2026-10-18 22:46:00 +02:00
Maurice fca42d46d9 refactor(journey): the phone's trip access list as its own component
The list of who a linked trip lets in took MJourneySettingsSheet past the 300-line limit for a component (max-lines-per-function), which turned the client lint on dev red. It is MTripAccessList beside it now; the markup is unchanged.
2026-10-18 22:36:57 +02:00
Maurice 532010aa79 feat(journey): the journal settings list who a linked trip lets in
Under the contributors, the journey settings on desktop and phone get a section of their own, Through trips, with everyone a trip's journal link lets in, their role and the trip they come through. It is read only there, since the trip's settings manage it, and it leaves out whoever is a contributor already. getJourneyFull answers it as trip_access.
2026-10-18 22:04:41 +02:00
Maurice 451167bde3 fix(trips): the settings button as a pill beside the trip's, and the dialog centred
The settings button was a bare round icon next to the trip's back pill; it is the same pill now, with the gear and the word Settings. The settings dialog opens centred again: its fixed height already keeps it from jumping between areas, so it does not need to be pinned to the top.
2026-10-18 22:04:40 +02:00
Maurice 92181cdefb test(trips): the suites follow the settings dialog's areas and the journal link
The dialog's nav buttons are named by their area alone (aria-label), so a hint line naming invites no longer matches the invite button. The sharing cases open the Sharing area first, the wiring and More sheet tests drive the settings button and row, the hook test reads the toasts as keys (renderHook loads no locale), and the lazy list and store registry know the journal panel and store.
2026-10-18 20:49:38 +02:00
Maurice ffa7333bb1 test(journey): the journal link repository on its own, without the two reads nothing called
linkRecipientUserIds and listTripIdsForJourney had no caller and kept src/db/repositories under its 99 % statement floor. TJLREPO-001..007 cover the link rows, the members list, the reach across trips ending with the membership, the hide-skeletons choice of a link member, the role precedence and leaveJournalLink.
2026-10-18 20:40:46 +02:00
Maurice 48d573c60d refactor(trips): leaving a trip tidies its journal link in the roster's own repository
The departure port needed a global binding that every suite building TripMembersModule without AppModule lacked. The roster's repository now deletes the leaver's row on the trip's journal link itself (leaveJournalLink), inside the transaction that removes the member: no edge into journey, no DI, no test stub, and the eight suites the stub touched are back as they were.

The schema inventory tests count the two new tables and their NOT NULL columns, the legacy step map lists the migration as post-legacy, and the TJL scenes give their users numbered names so cases sharing one database do not collide.
2026-10-18 20:02:09 +02:00
Maurice 33c5c7decc test(shared): the event inventory counts trip:journal:changed 2026-10-18 19:23:56 +02:00
Maurice 3db20dd71c chore(client): theme baseline down one for the share button the navbar lost 2026-10-18 19:13:36 +02:00
Maurice 0751b3e8c9 fix(trips): the journal link's toast and tab texts as literal keys
lint:i18n-keys only reaches a key through a literal t() or a *Key property: the three save toasts were passed to run() as bare keys and the tab label came out of TRIP_TAB_LABEL_KEYS, so four new en keys counted as unused. They are translated where they are named now.
2026-10-18 19:04:10 +02:00
Maurice 4a074ec93c refactor(journey): the link role from the generated entity, not a second union
TripJournalLinksRepository spelled 'viewer' | 'editor' by hand while the generated entity exports the same roles, which knip then counted as an unused export. The repository now derives its type and values from the entity.
2026-10-18 18:54:02 +02:00
Maurice a6da69ee36 refactor(trips): the member departure port in common/, where both sides may import it
Declared in trip-members, the port gave journey an edge into the roster, which reaches journey through other domains: the cycle again, the other way round. In the shared kernel neither domain owns it.
2026-10-18 18:44:02 +02:00
Maurice ce8d1e5a0a refactor(trips): leaving a trip reaches the journal link through a port, not an import
TripMembersService called TripJournalLinkService directly, which made trip-members and journey depend on each other (lint:boundaries). The roster now declares TRIP_MEMBER_DEPARTURE and calls memberLeft in the transaction that removes the member; the journey domain binds it globally (TripMemberDepartureModule), the way JourneyAccessModule binds the gateway's JOURNEY_ACCESS. Suites that build TripMembersModule alone import MemberDepartureStubModule.
2026-10-18 18:31:26 +02:00
Maurice 340d429415 test(trips): the journal link stub beside the other journey share helpers
budget.service.db and trips.service tests are held at their size baselines; the stub now comes from journey-share-repos, which both already import, so neither suite grows.
2026-10-18 18:17:04 +02:00
Maurice 89c96ed554 fix(trips): the embedded journal's offsets as classes on its wrapper, not in index.css
index.css is held at its line baseline. The two variables the embedded journal overrides (--nav-h on the desktop, --m-safe-top on the phone) now sit as arbitrary properties on the wrapper that needs them.
2026-10-18 18:04:34 +02:00
Maurice 889e88c362 refactor(trips): the journal member choice in a hook of its own, the test seed without an await loop
useTripJournalLink had grown past the 120-line limit for a function. The member list (candidates per journal, roles, who is new) is now useJournalMemberChoice. What loaded is kept with the journal it belongs to, so a new pick shows nothing until its own people are in, without resetting state by hand. The integration test adds its trip members with Promise.all.
2026-10-18 17:55:41 +02:00
Maurice fe05afec19 feat(trips): trip settings with a linked journal tab
The Share button becomes the trip's Settings, a round button beside the back
pill. The dialog is rebuilt around three areas (People, Sharing, Journal) with
a side navigation, a fixed height and 960 px width on wide screens.

Journal links one of your journals from the Journey addon to the trip. Every
trip member is ticked as a reader by default, each can be raised to editor or
left out, and the people a save would let in for the first time are named
before it saves. The link can be changed or removed. With a link the trip gets
a Journal tab on desktop and phone that shows the Journey addon's own view,
live through its sync and trip:journal:changed. Nothing shows with the addon
off.

members.shareTrip and nav.share are gone with the button that used them.
2026-10-18 17:41:25 +02:00
Maurice 89f3055a29 fix(journey): one journal link per trip as a unique trip id, factories within tsc's depth
The link table keyed by the trip id made both entities a one-to-one, and the
test typecheck failed with TS2589 in the itinerary factory. It now holds its
own id with trip_id UNIQUE, as trip_invite_tokens does, and its members hang
off link_id.

The factory error was not this table's alone: any further relation on trips,
journeys or users tipped the same check. insertRow and createRow now take a
caller's overrides as their own argument instead of a spread checked against
RequiredEntityData, so a new relation no longer breaks the itinerary and tour
factories.

The suites that hand-build TripMembersService get a journalLinksStub.
2026-10-18 17:41:18 +02:00
Maurice 80b6739362 feat(journey): a trip can link a journal and open it to the members it picks
A trip can now show one journal from the Journey addon as its own: any
journal its owner picks, and one journal on as many trips as they like.
The trip members chosen for the link get into the journal as viewers or
editors, see it in their own journal list and follow it live, for as long
as the link holds them and they stay on the trip.

That access is never copied into journey_contributors. It is read where
journal access is read: the contributor repository answers with the
stronger of a direct role and a link role, the journal list and the live
update recipients take the link members in, and the photo access check
goes the same way. Every one of those reads starts from linkReach, which
only counts members still on the linking trip, so unlinking, deselecting
someone and their leaving the trip all end exactly what the link gave.
A member who leaves is also taken off the link, so coming back later does
not quietly reopen the journal.

Linking or changing the members takes the journal's owner and the new
journal_link trip permission (trip owner by default); unlinking takes
either. /api/trips/:tripId/journal answers the link as each member may see
it, /candidates says who a journal would be opened to for the first time,
and four MCP tools do the same over the same service. A trip:journal:changed
event tells the trip to look again.
2026-10-18 15:59:33 +02:00
538 changed files with 19849 additions and 737 deletions
+20
View File
@@ -257,6 +257,15 @@ jobs:
# migration that changes a column regenerates it in the same change. # migration that changes a column regenerates it in the same change.
run: cd server && npm run check:db-types run: cd server && npm run check:db-types
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint - name: Lint
# Errors fail, and the warnings per rule may not pass # Errors fail, and the warnings per rule may not pass
# scripts/eslint-baseline.json (src, tests and eslint-disable'd # scripts/eslint-baseline.json (src, tests and eslint-disable'd
@@ -264,6 +273,7 @@ jobs:
# program, which outgrows Node's default ~4 GB heap on the runner. # program, which outgrows Node's default ~4 GB heap on the runner.
env: env:
NODE_OPTIONS: --max-old-space-size=8192 NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd server && npm run lint:warnings run: cd server && npm run lint:warnings
- name: Formatting check - name: Formatting check
@@ -395,6 +405,15 @@ jobs:
# one included, has to be clean (scripts/strict-lint.mjs). # one included, has to be clean (scripts/strict-lint.mjs).
run: cd client && npm run lint:strict run: cd client && npm run lint:strict
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint - name: Lint
# Fails on any ESLint error, like lint:check did, and on any rule # Fails on any ESLint error, like lint:check did, and on any rule
# whose warning count rises above scripts/eslint-baseline.json. # whose warning count rises above scripts/eslint-baseline.json.
@@ -402,6 +421,7 @@ jobs:
# whole TypeScript program, so it gets the heap the server lint gets. # whole TypeScript program, so it gets the heap the server lint gets.
env: env:
NODE_OPTIONS: --max-old-space-size=8192 NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd client && npm run lint:warnings run: cd client && npm run lint:warnings
- name: Page pattern check - name: Page pattern check
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"src": { "src": {
"@typescript-eslint/no-non-null-assertion": 404, "@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 875, "@typescript-eslint/no-misused-promises": 874,
"no-console": 88, "no-console": 88,
"complexity": 230, "complexity": 230,
"@typescript-eslint/no-explicit-any": 147, "@typescript-eslint/no-explicit-any": 147,
+91 -4
View File
@@ -2,6 +2,7 @@
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs). * The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
*/ */
import { ESLint } from 'eslint'; import { ESLint } from 'eslint';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path'; import { join } from 'node:path';
import ts from 'typescript'; import ts from 'typescript';
import { import {
@@ -137,6 +138,90 @@ export async function lintClient(root) {
})); }));
} }
/** The ref new warnings are measured against: TREK_LINT_BASE (the CI sets it), else origin/dev. */
const baseRef = () => process.env.TREK_LINT_BASE || 'origin/dev';
/** Git in the workspace: its output, or null when it fails (no repository, no such ref). */
function gitIn(root, args) {
try {
return execFileSync('git', args, {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: 1 << 28,
});
} catch {
return null;
}
}
/** The messages of one rule in one area of a single file's lint result. */
function ofRule(results, rule, area) {
const result = results[0];
if (!result) return [];
const pool =
area === 'suppressed' ? (result.suppressedMessages ?? []) : result.messages.filter((m) => m.severity === 1);
return pool.filter((m) => ruleOf(m) === rule);
}
/**
* Where a grown rule's new warnings sit: every file among `keys` (the files
* holding the rule now) that differs from `ref` and holds more of the rule
* than it did there, each of its warnings of the rule as `file:line message`.
* The count alone names no file, and the files holding the most are old ones;
* this names the file that tipped it. Null when `ref` is not at hand (a clone
* without it), and the report then keeps to the files holding the most.
*/
export async function newWarningsOf({ root, rule, area, keys, lintText, ref = baseRef(), git = gitIn }) {
const changed = git(root, ['diff', '--name-only', '--relative', ref, '--', '.']);
if (changed === null) return null;
const differs = new Set(changed.split('\n').filter(Boolean));
const found = await Promise.all(
keys
.filter((key) => differs.has(key))
.map(async (key) => {
const path = join(root, key);
// A typed rule can trip over a text that is not the file on disk (the
// base version); then the file's own warnings are told, marked so.
const lint = async (text) => {
try {
return { messages: ofRule(await lintText(text, path), rule, area) };
} catch {
return null;
}
};
const now = await lint(readText(path));
if (!now) return [];
const before = git(root, ['show', `${ref}:./${key}`]);
const was = before === null ? { messages: [] } : await lint(before);
if (was && now.messages.length <= was.messages.length) return [];
const note = was ? '' : ' (the base version could not be linted)';
return now.messages.map((m) => `${key}:${m.line ?? 0} ${m.message}${note}`);
})
);
return found.flat();
}
/** Prints, under the grown counts, the files and lines behind each one (see newWarningsOf). */
async function reportNew(grown, { root, files, lintText, error }) {
if (!grown.length || !lintText) return;
const found = await Promise.all(
grown.map(({ area, rule }) => newWarningsOf({ root, rule, area, keys: Object.keys(files[area][rule]), lintText }))
);
grown.forEach(({ area, rule }, i) => {
const lines = found[i];
if (!lines?.length) return;
error(`New ${rule} warning(s) in ${area}, against ${baseRef()}:`);
for (const line of lines) error(` ${line}`);
});
}
/** Lints one file's text with the client's own config, for newWarningsOf. */
function lintTextClient(root) {
const eslint = new ESLint({ cwd: root });
return (text, filePath) => eslint.lintText(text, { filePath });
}
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0); const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
/** /**
@@ -148,6 +233,7 @@ export async function check({
baselinePath = join(root, 'scripts/eslint-baseline.json'), baselinePath = join(root, 'scripts/eslint-baseline.json'),
update = false, update = false,
lint = lintClient, lint = lintClient,
lintText = lint === lintClient ? lintTextClient : null,
log = console.log, log = console.log,
error = console.error, error = console.error,
}) { }) {
@@ -160,13 +246,13 @@ export async function check({
} }
for (const line of errors) error(`FAIL ${line}`); for (const line of errors) error(`FAIL ${line}`);
let grown = 0; const grown = [];
let stale = 0; let stale = 0;
for (const area of AREAS) { for (const area of AREAS) {
for (const [rule, n] of Object.entries(counts[area])) { for (const [rule, n] of Object.entries(counts[area])) {
const allowed = baseline[area][rule] ?? 0; const allowed = baseline[area][rule] ?? 0;
if (n <= allowed) continue; if (n <= allowed) continue;
grown++; grown.push({ area, rule });
const top = Object.entries(files[area][rule]) const top = Object.entries(files[area][rule])
.sort(([, a], [, b]) => b - a) .sort(([, a], [, b]) => b - a)
.slice(0, 5) .slice(0, 5)
@@ -182,7 +268,8 @@ export async function check({
}); });
stale += fallen.length; stale += fallen.length;
} }
if (grown) { await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
if (grown.length) {
error( error(
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' + 'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
'Run npx eslint <file> to see each one.' 'Run npx eslint <file> to see each one.'
@@ -191,5 +278,5 @@ export async function check({
log( log(
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)` `eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
); );
return errors.length || grown || stale ? 1 : 0; return errors.length || grown.length || stale ? 1 : 0;
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667, "src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764, "src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816, "src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1409, "src/components/Map/MapView.tsx": 1402,
"src/components/Map/MapViewGL.test.tsx": 2920, "src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2299, "src/components/Map/MapViewGL.tsx": 2299,
"src/components/Map/RouteCalculator.ts": 1195, "src/components/Map/RouteCalculator.ts": 1195,
+1 -1
View File
@@ -55,7 +55,7 @@
"components/Layout/DemoBanner.tsx": 25, "components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 10, "components/Layout/InAppNotificationBell.tsx": 10,
"components/Layout/MobileTopBar.tsx": 51, "components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 22, "components/Layout/Navbar.tsx": 21,
"components/Layout/NewVersionNotice.tsx": 3, "components/Layout/NewVersionNotice.tsx": 3,
"components/Layout/OfflineBanner.tsx": 2, "components/Layout/OfflineBanner.tsx": 2,
"components/Layout/PageSidebar.tsx": 3, "components/Layout/PageSidebar.tsx": 3,
+13
View File
@@ -0,0 +1,13 @@
import { paddleRegionLoadResponseSchema, type PaddleRegionLoadRequest, type PaddleRegionLoadState } from '@trek/shared';
import apiClient from './client';
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
const GEOFABRIK = { timeout: 30_000 };
/** PAD-11C: the water data around places no paddle region covers yet, loaded or asked for by the server. */
export const paddleRegionLoadApi = {
load: (request: PaddleRegionLoadRequest, signal?: AbortSignal): Promise<PaddleRegionLoadState[]> =>
apiClient
.post('/paddle/regions/load', request, { signal, ...GEOFABRIK })
.then((r) => paddleRegionLoadResponseSchema.parse(r.data).points),
};
+11 -2
View File
@@ -2,17 +2,19 @@ import {
paddleRegionCatalogResponseSchema, paddleRegionCatalogResponseSchema,
paddleRegionListResponseSchema, paddleRegionListResponseSchema,
paddleRegionSchema, paddleRegionSchema,
paddleRegionSettingsSchema,
successResponseSchema, successResponseSchema,
type PaddleRegionRequest, type PaddleRegionRequest,
type PaddleRegionSettings,
} from '@trek/shared'; } from '@trek/shared';
import apiClient from './client'; import apiClient from './client';
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */ /** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
const GEOFABRIK = { timeout: 30_000 }; const GEOFABRIK = { timeout: 30_000 };
/** PAD-05A, the admin side: the regions TREK reads paddle data for. */ /** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */
export const paddleRegionsApi = { export const paddleRegionsApi = {
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions), list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)),
catalog: (q: string, signal?: AbortSignal) => catalog: (q: string, signal?: AbortSignal) =>
apiClient apiClient
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK }) .get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
@@ -29,4 +31,11 @@ export const paddleRegionsApi = {
apiClient apiClient
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest) .post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
.then((r) => successResponseSchema.parse(r.data)), .then((r) => successResponseSchema.parse(r.data)),
decline: (id: string) =>
apiClient
.post('/admin/paddle-regions/decline', { id } satisfies PaddleRegionRequest)
.then((r) => successResponseSchema.parse(r.data)),
settings: () => apiClient.get('/admin/paddle-regions/settings').then((r) => paddleRegionSettingsSchema.parse(r.data)),
saveSettings: (settings: PaddleRegionSettings) =>
apiClient.put('/admin/paddle-regions/settings', settings).then((r) => paddleRegionSettingsSchema.parse(r.data)),
}; };
+10
View File
@@ -0,0 +1,10 @@
import { paddleRouteResponseSchema, type PaddleRouteInput, type PaddleRouteResponse } from '@trek/shared';
import apiClient from './client';
/** PAD-11: legs of a paddle tour planned on the water by the server's router. */
export const paddleRouteApi = {
plan: (tripId: number | string, request: PaddleRouteInput, signal?: AbortSignal): Promise<PaddleRouteResponse> =>
apiClient
.post(`/trips/${tripId}/paddle/route`, request, { signal })
.then((r) => paddleRouteResponseSchema.parse(r.data)),
};
+31
View File
@@ -0,0 +1,31 @@
import type {
TripJournalCandidatesResponse,
TripJournalLinkMemberRequest,
TripJournalLinkResponse,
} from '@trek/shared';
import apiClient from './client';
/**
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
* and who it is open to. In its own file rather than in the `client.ts` god
* module; repo/tripJournalLinkRepo.ts is the one caller.
*/
export const tripJournalApi = {
get: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
link: (
tripId: number,
journeyId: number,
members: TripJournalLinkMemberRequest[]
): Promise<TripJournalLinkResponse> =>
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
/** The journals this person can open; the dialog offers the ones they own. */
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
apiClient.get('/journeys').then((r) => r.data),
};
+4
View File
@@ -93,6 +93,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// the bag ping: what a run changed is per-binding server state, and the // the bag ping: what a run changed is per-binding server state, and the
// files it moved arrive as file:* events. // files it moved arrive as file:* events.
'docsync:changed', 'docsync:changed',
// Linked journal: components/Trips/useTripJournalLink asks again which journal
// the trip shows and who it is open to. Content-free, because that answer is
// per person (a member left off the link must not learn its title).
'trip:journal:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener) // Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress', 'import:progress',
'import:done', 'import:done',
@@ -0,0 +1,167 @@
import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared';
import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
const SIZES = [100, 250, 500, 1000, 2000] as const;
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
function useMegabytes() {
const { language } = useTranslation();
return (mb: number) =>
new Intl.NumberFormat(language, {
style: 'unit',
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
maximumFractionDigits: mb >= 1000 ? 1 : 0,
}).format(mb >= 1000 ? mb / 1000 : mb);
}
const MB = 1024 * 1024;
/**
* PAD-11C: how TREK gets the water data of a place a paddler plans in. The
* mode, and in the automatic one the largest region it loads without asking.
*/
export function AutoLoadSettings({ admin }: { admin: Admin }) {
const { t } = useTranslation();
const megabytes = useMegabytes();
const { settings, saving, save } = admin.autoLoad;
if (!settings) return null;
const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number])
? SIZES
: [...SIZES, settings.max_mb].sort((a, b) => a - b);
return (
<div data-testid="paddle-auto-load" className="flex min-w-0 flex-col gap-2.5">
<div className="min-w-0">
<span className={LABEL}>{t('admin.paddleRegions.auto.title')}</span>
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
<Segmented<PaddleRegionAutoMode>
value={settings.mode}
onChange={(mode) => void save({ mode })}
label={t('admin.paddleRegions.auto.title')}
fill
options={MODES.map((mode) => {
const Icon = MODE_ICONS[mode];
return {
value: mode,
label: t(`admin.paddleRegions.auto.mode.${mode}`),
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
};
})}
/>
</fieldset>
<SettingsHint className="mt-1.5">{t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)}</SettingsHint>
</div>
{settings.mode === 'auto' && (
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
<span className={LABEL}>{t('admin.paddleRegions.auto.maxSize')}</span>
<ChoiceChips<string>
value={String(settings.max_mb)}
label={t('admin.paddleRegions.auto.maxSize')}
onChange={(value) => void save({ max_mb: Number(value) })}
options={sizes.map((mb) => ({ value: String(mb), label: megabytes(mb) }))}
/>
<SettingsHint className="mt-1.5">{t('admin.paddleRegions.auto.maxSizeHint')}</SettingsHint>
</fieldset>
)}
</div>
);
}
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
const { t, language } = useTranslation();
const megabytes = useMegabytes();
const busy = admin.busyId === request.id;
const date = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' });
return (
<div
className={`flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
data-testid="paddle-region-request"
>
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-1">
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
{request.name}
</span>
<span className="flex flex-wrap items-center gap-1.5">
{request.declined ? (
<StatusPill tone="danger" icon={<Ban size={10} strokeWidth={2.4} />}>
{t('admin.paddleRegions.requests.declined')}
</StatusPill>
) : (
<StatusPill tone="warning">{t(`admin.paddleRegions.requests.reason.${request.reason}`)}</StatusPill>
)}
{request.bytes !== null && <StatusPill>{megabytes(Math.max(1, Math.round(request.bytes / MB)))}</StatusPill>}
{request.requested_by && (
<StatusPill>{t('admin.paddleRegions.requests.by', { user: request.requested_by })}</StatusPill>
)}
<StatusPill>{date}</StatusPill>
</span>
</div>
<span className="flex flex-none items-center gap-1.5">
{!request.declined && (
<button
type="button"
disabled={admin.busyId !== null}
onClick={() => void admin.decline(request.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{t('admin.paddleRegions.requests.decline')}
</button>
)}
<button
type="button"
disabled={admin.busyId !== null}
onClick={() => void admin.approve(request.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{busy ? <Loader2 size={13} className="animate-spin" /> : <CloudDownload size={13} strokeWidth={2.2} />}
{t('admin.paddleRegions.requests.approve')}
</button>
</span>
</div>
);
}
/** The regions paddlers asked for that wait for an admin, the open ones first. */
export function RequestList({ admin }: { admin: Admin }) {
const { t } = useTranslation();
if (admin.requests.length === 0) return null;
const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined));
const open = admin.requests.filter((request) => !request.declined).length;
return (
<div className="flex min-w-0 flex-col gap-2" data-testid="paddle-region-requests">
<span className={`${LABEL} mb-0 flex items-center gap-1.5`}>
{t('admin.paddleRegions.requests.title')}
{open > 0 && <StatusPill tone="warning">{open}</StatusPill>}
</span>
<SettingRows>
{ordered.map((request) => (
<RequestRow key={request.id} request={request} admin={admin} />
))}
</SettingRows>
</div>
);
}
/** A region TREK loaded by itself for a paddler, told apart from those an admin added. */
export function AutoOriginPill() {
const { t } = useTranslation();
return (
<Tooltip label={t('admin.paddleRegions.origin.autoHint')}>
<span className="inline-flex" data-testid="paddle-region-auto">
<StatusPill icon={<Sparkles size={10} strokeWidth={2.2} />}>{t('admin.paddleRegions.origin.auto')}</StatusPill>
</span>
</Tooltip>
);
}
@@ -1,4 +1,4 @@
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a // FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-027: the paddle regions card in the admin area, drawn from a
// controlled usePaddleRegionsAdmin (the hook has its own test). // controlled usePaddleRegionsAdmin (the hook has its own test).
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel. // FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared'; import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
@@ -25,11 +25,13 @@ function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
id: 'oberpfalz', id: 'oberpfalz',
name: 'Oberpfalz', name: 'Oberpfalz',
status: 'ready', status: 'ready',
origin: 'admin',
source_timestamp: '2026-10-03T12:00:00Z', source_timestamp: '2026-10-03T12:00:00Z',
imported_at: '2026-10-04T08:00:00Z', imported_at: '2026-10-04T08:00:00Z',
feature_count: 1234, feature_count: 1234,
error: null, error: null,
progress: null, progress: null,
network: null,
...overrides, ...overrides,
}; };
} }
@@ -56,6 +58,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
toursOn: true, toursOn: true,
offline: false, offline: false,
regions: [], regions: [],
requests: [],
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
loading: false, loading: false,
error: null, error: null,
retry: vi.fn(), retry: vi.fn(),
@@ -63,6 +67,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
busyId: null, busyId: null,
add: vi.fn(async () => {}), add: vi.fn(async () => {}),
refresh: vi.fn(async () => {}), refresh: vi.fn(async () => {}),
approve: vi.fn(async () => {}),
decline: vi.fn(async () => {}),
removing: null, removing: null,
askRemove: vi.fn(), askRemove: vi.fn(),
cancelRemove: vi.fn(), cancelRemove: vi.fn(),
@@ -295,6 +301,91 @@ describe('PaddleRegionsPanel', () => {
}); });
}); });
describe('PaddleRegionsPanel water network (PAD-11)', () => {
it('FE-ADMIN-PADDLEREG-022: a region with its network shows the water it holds, the rest in the tooltip', async () => {
const network = {
water_km: 1364.5,
lakes: 266,
portages: 14,
inferred_portages: 232,
gaps: 63,
locks: 17,
built_at: '2026-10-17T15:30:20Z',
bytes: 1496816,
};
show({ regions: [region({ network })] });
const pill = within(rowOf('Oberpfalz')).getByTestId('paddle-region-network');
expect(pill).toHaveTextContent('1,365 km of water');
fireEvent.mouseEnter(pill);
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'266 lakes, 14 mapped portages, 232 assumed carries, 17 locks, 63 bridged gaps'
);
});
it('FE-ADMIN-PADDLEREG-023: a region read before routing existed asks to be read again; one never read or being read says nothing', () => {
show({
regions: [
region({ id: 'old', name: 'Old read', network: null }),
region({ id: 'new', name: 'Never read', imported_at: null, status: 'queued', network: null }),
],
});
expect(within(rowOf('Old read')).getByText('No water network')).toBeInTheDocument();
expect(within(rowOf('Never read')).queryByText('No water network')).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-024: offers automatic, on request and off, with the size limit only for automatic', () => {
const save = vi.fn(async () => {});
show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } });
const auto = screen.getByTestId('paddle-auto-load');
expect(within(auto).getByRole('button', { name: /Automatic/ })).toHaveAttribute('aria-pressed', 'true');
expect(within(auto).getByText('Largest region loaded automatically')).toBeInTheDocument();
fireEvent.click(within(auto).getByRole('button', { name: /On request/ }));
expect(save).toHaveBeenCalledWith({ mode: 'ask' });
fireEvent.click(within(auto).getByRole('button', { name: '1 GB' }));
expect(save).toHaveBeenCalledWith({ max_mb: 1000 });
});
it('FE-ADMIN-PADDLEREG-025: hides the size limit unless loading is automatic, and shows nothing before the setting is read', () => {
show({ autoLoad: { settings: { mode: 'off', max_mb: 500 }, saving: false, save: vi.fn() } });
expect(screen.queryByText('Largest region loaded automatically')).not.toBeInTheDocument();
expect(screen.getByText(/TREK loads nothing by itself/)).toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-026: lists the requests, the open ones first, to load or decline', () => {
const approve = vi.fn(async () => {});
const decline = vi.fn(async () => {});
const base = {
bytes: 734_003_200,
requested_by: 'elmo',
requested_at: '2026-10-18T09:00:00Z',
};
show({
approve,
decline,
requests: [
{ ...base, id: 'norway', name: 'Norway', reason: 'too_large', declined: true },
{ ...base, id: 'brandenburg', name: 'Brandenburg', reason: 'limit', declined: false },
],
});
const rows = screen.getAllByTestId('paddle-region-request');
expect(rows[0]).toHaveTextContent('Brandenburg');
expect(rows[0]).toHaveTextContent('Daily limit');
expect(rows[0]).toHaveTextContent('by elmo');
expect(rows[1]).toHaveTextContent('Declined');
fireEvent.click(within(rows[0]).getByRole('button', { name: 'Decline' }));
expect(decline).toHaveBeenCalledWith('brandenburg');
fireEvent.click(within(rows[1]).getByRole('button', { name: /Load/ }));
expect(approve).toHaveBeenCalledWith('norway');
expect(within(rows[1]).queryByRole('button', { name: 'Decline' })).toBeNull();
});
it('FE-ADMIN-PADDLEREG-027: marks a region TREK loaded by itself', () => {
show({ regions: [region({ id: 'mv', name: 'Mecklenburg', origin: 'auto' }), READY] });
expect(within(rowOf('Mecklenburg')).getByTestId('paddle-region-auto')).toHaveTextContent('Automatic');
expect(within(rowOf('Oberpfalz')).queryByTestId('paddle-region-auto')).toBeNull();
});
});
describe('paddleRegionsModel', () => { describe('paddleRegionsModel', () => {
it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => { it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => {
expect(isReading({ status: 'queued' })).toBe(true); expect(isReading({ status: 'queued' })).toBe(true);
@@ -15,6 +15,7 @@ import { EditorField, INPUT } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell'; import { fs } from '../../shared/DialogShell';
import ErrorBoundary from '../../shared/ErrorBoundary'; import ErrorBoundary from '../../shared/ErrorBoundary';
import { Tooltip } from '../../shared/Tooltip'; import { Tooltip } from '../../shared/Tooltip';
import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts';
import { progressPercent, regionStatusTone } from './paddleRegionsModel'; import { progressPercent, regionStatusTone } from './paddleRegionsModel';
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin'; import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
@@ -22,8 +23,10 @@ type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
/** /**
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for, * PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
* picked from Geofabrik's list. Both the desktop and the phone admin show * picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a
* this one card under the addons, while the Tours addon is on. * place a paddler plans in by itself, with the regions that wait for an admin.
* Both the desktop and the phone admin show this one card under the addons,
* while the Tours addon is on.
*/ */
export default function PaddleRegionsPanel() { export default function PaddleRegionsPanel() {
return ( return (
@@ -67,6 +70,8 @@ function Panel() {
</div> </div>
)} )}
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0"> <fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
<AutoLoadSettings admin={admin} />
<RequestList admin={admin} />
<CatalogSearch admin={admin} /> <CatalogSearch admin={admin} />
<RegionList admin={admin} /> <RegionList admin={admin} />
</fieldset> </fieldset>
@@ -216,6 +221,41 @@ function RegionStatus({ region }: { region: PaddleRegion }) {
); );
} }
/**
* PAD-11: the water network the router follows in a region, as one figure
* with the rest in its tooltip. A region read before routing existed says it
* needs reading again; a network is never claimed where none was built.
*/
function RegionNetwork({ region }: { region: PaddleRegion }) {
const { t, language } = useTranslation();
const { network } = region;
if (!network) {
if (!region.imported_at || region.status === 'importing') return null;
return (
<Tooltip label={t('admin.paddleRegions.network.missingHint')}>
<span className="inline-flex">
<StatusPill tone="warning">{t('admin.paddleRegions.network.missing')}</StatusPill>
</span>
</Tooltip>
);
}
const km = new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(network.water_km);
const details = [
t('admin.paddleRegions.network.lakes', { count: network.lakes }),
t('admin.paddleRegions.network.portages', { count: network.portages }),
t('admin.paddleRegions.network.inferred', { count: network.inferred_portages }),
t('admin.paddleRegions.network.locks', { count: network.locks }),
t('admin.paddleRegions.network.gaps', { count: network.gaps }),
].join(', ');
return (
<Tooltip label={details}>
<span className="inline-flex" data-testid="paddle-region-network">
<StatusPill>{t('admin.paddleRegions.network.km', { km })}</StatusPill>
</span>
</Tooltip>
);
}
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) { function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
const { t, language } = useTranslation(); const { t, language } = useTranslation();
const busy = admin.busyId === region.id; const busy = admin.busyId === region.id;
@@ -239,10 +279,12 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
</span> </span>
<span className="flex flex-wrap items-center gap-1.5"> <span className="flex flex-wrap items-center gap-1.5">
<RegionStatus region={region} /> <RegionStatus region={region} />
{region.origin === 'auto' && <AutoOriginPill />}
{region.imported_at && ( {region.imported_at && (
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill> <StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
)} )}
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>} {region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
<RegionNetwork region={region} />
</span> </span>
{region.status === 'failed' && region.error && ( {region.status === 'failed' && region.error && (
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}> <p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
@@ -0,0 +1,50 @@
// FE-HOOK-PADDLE-AUTOLOAD-001 to 003: how TREK loads regions by itself, read and saved in the admin area (PAD-11C).
import { act, renderHook, waitFor } from '@testing-library/react';
import { createElement, type ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TranslationProvider } from '../../../i18n/TranslationContext';
const repo = vi.hoisted(() => ({ settings: vi.fn(), saveSettings: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/paddleRegionRepo', () => ({ paddleRegionRepo: repo }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const { usePaddleAutoLoad } = await import('./usePaddleAutoLoad');
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
describe('usePaddleAutoLoad', () => {
beforeEach(() => {
vi.clearAllMocks();
repo.settings.mockResolvedValue({ mode: 'auto', max_mb: 500 });
repo.saveSettings.mockImplementation(async (settings: unknown) => settings);
});
it('FE-HOOK-PADDLE-AUTOLOAD-001: reads the setting once the card shows, and not offline', async () => {
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
await waitFor(() => expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 }));
renderHook(() => usePaddleAutoLoad(true, true), { wrapper });
expect(repo.settings).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-PADDLE-AUTOLOAD-002: shows a change at once and keeps what the server saved', async () => {
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
await waitFor(() => expect(result.current.settings).not.toBeNull());
await act(async () => {
await result.current.save({ mode: 'ask' });
});
expect(repo.saveSettings).toHaveBeenCalledWith({ mode: 'ask', max_mb: 500 });
expect(result.current.settings).toEqual({ mode: 'ask', max_mb: 500 });
});
it('FE-HOOK-PADDLE-AUTOLOAD-003: takes a refused change back and says so', async () => {
repo.saveSettings.mockRejectedValueOnce(new Error('nope'));
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
await waitFor(() => expect(result.current.settings).not.toBeNull());
await act(async () => {
await result.current.save({ max_mb: 2000 });
});
expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 });
expect(toast.error).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,59 @@
import type { PaddleRegionSettings } from '@trek/shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { paddleRegionRepo } from '../../../repo/paddleRegionRepo';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
/**
* PAD-11C in the admin area: how TREK loads the water data of a place a
* paddler plans in, read once the card shows and saved on every change. A
* change shows at once and goes back, with a toast, when the server refuses
* it. While one is saved the controls rest, so two saves never cross.
*/
export function usePaddleAutoLoad(active: boolean, offline: boolean) {
const { t } = useTranslation();
const toast = useToast();
const [settings, setSettings] = useState<PaddleRegionSettings | null>(null);
const [saving, setSaving] = useState(false);
const pending = useRef(false);
useEffect(() => {
if (!active || offline) return;
let cancelled = false;
paddleRegionRepo.settings().then(
(loaded) => {
if (!cancelled) setSettings(loaded);
},
(err: unknown) => {
if (!cancelled) toast.error(getApiErrorMessage(err, t('common.error')));
}
);
return () => {
cancelled = true;
};
}, [active, offline, t, toast]);
const save = useCallback(
async (change: Partial<PaddleRegionSettings>) => {
if (!settings || pending.current) return;
const before = settings;
const next = { ...settings, ...change };
pending.current = true;
setSaving(true);
setSettings(next);
try {
setSettings(await paddleRegionRepo.saveSettings(next));
} catch (err) {
setSettings(before);
toast.error(getApiErrorMessage(err, t('common.error')));
} finally {
pending.current = false;
setSaving(false);
}
},
[settings, t, toast]
);
return { settings, saving, save };
}
@@ -24,11 +24,15 @@ const repo = vi.hoisted(() => ({
add: vi.fn<(id: string) => Promise<PaddleRegion>>(), add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(), refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
remove: vi.fn<(id: string) => Promise<{ success: true }>>(), remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
decline: vi.fn<(id: string) => Promise<{ success: true }>>(),
settings: vi.fn(async () => ({ mode: 'auto' as const, max_mb: 500 })),
saveSettings: vi.fn(async (settings: { mode: 'auto' | 'ask' | 'off'; max_mb: number }) => settings),
})); }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({ vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()), ...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
paddleRegionRepo: repo, // The list answers its regions and no requests unless a test says otherwise (PAD-11C).
paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) },
})); }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
@@ -40,11 +44,13 @@ function region(id: string, status: PaddleRegion['status'], over: Partial<Paddle
id, id,
name: id, name: id,
status, status,
origin: 'admin',
source_timestamp: null, source_timestamp: null,
imported_at: null, imported_at: null,
feature_count: 0, feature_count: 0,
error: null, error: null,
progress: null, progress: null,
network: null,
...over, ...over,
}; };
} }
@@ -1,4 +1,4 @@
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared'; import type { PaddleRegion, PaddleRegionCatalogEntry, PaddleRegionPending } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNetworkMode } from '../../../hooks/useNetworkMode'; import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { useTranslation } from '../../../i18n'; import { useTranslation } from '../../../i18n';
@@ -7,6 +7,7 @@ import { useAddonStore } from '../../../store/addonStore';
import { getApiErrorMessage } from '../../../types'; import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast'; import { useToast } from '../../shared/Toast';
import { isReading } from './paddleRegionsModel'; import { isReading } from './paddleRegionsModel';
import { usePaddleAutoLoad } from './usePaddleAutoLoad';
/** How often the list is asked again while a region waits or is read. */ /** How often the list is asked again while a region waits or is read. */
export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000; export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000;
@@ -23,12 +24,14 @@ function errorText(t: Translate, err: unknown): string {
} }
/** /**
* The regions as the server holds them, asked again every few seconds while * The regions as the server holds them, with those paddlers asked for, asked
* any of them is read, and on `reload` after a change. Every answer is set * again every few seconds while any of them is read, and on `reload` after a
* change. Every answer is set
* from the request's own callback, so a slow one never overwrites a newer. * from the request's own callback, so a slow one never overwrites a newer.
*/ */
function useRegionList(active: boolean, offline: boolean, t: Translate) { function useRegionList(active: boolean, offline: boolean, t: Translate) {
const [regions, setRegions] = useState<PaddleRegion[]>([]); const [regions, setRegions] = useState<PaddleRegion[]>([]);
const [requests, setRequests] = useState<PaddleRegionPending[]>([]);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [asked, setAsked] = useState(0); const [asked, setAsked] = useState(0);
@@ -38,7 +41,8 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
paddleRegionRepo.list().then( paddleRegionRepo.list().then(
(list) => { (list) => {
if (cancelled) return; if (cancelled) return;
setRegions(list); setRegions(list.regions);
setRequests(list.requests);
setError(null); setError(null);
setLoaded(true); setLoaded(true);
}, },
@@ -59,7 +63,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [active, offline, reading]); }, [active, offline, reading]);
const reload = useCallback(() => setAsked((n) => n + 1), []); const reload = useCallback(() => setAsked((n) => n + 1), []);
return { regions, loading: !loaded && !offline, error, reload }; return { regions, requests, loading: !loaded && !offline, error, reload };
} }
/** Geofabrik's list searched as the admin types, the last search winning. */ /** Geofabrik's list searched as the admin types, the last search winning. */
@@ -99,7 +103,8 @@ function useCatalogSearch(offline: boolean, t: Translate) {
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search * PAD-05A in the admin area: the regions TREK reads paddle data for, a search
* in Geofabrik's list to add one, reading a region again and removing it * in Geofabrik's list to add one, reading a region again and removing it
* after a confirmation. Shown only while the Tours addon is on; the status of * after a confirmation. Shown only while the Tours addon is on; the status of
* a read follows without reloading the page. * a read follows without reloading the page. PAD-11C: how TREK loads regions
* for paddlers by itself, and the regions they asked for, to load or decline.
*/ */
export function usePaddleRegionsAdmin() { export function usePaddleRegionsAdmin() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -107,6 +112,7 @@ export function usePaddleRegionsAdmin() {
const { offline } = useNetworkMode(); const { offline } = useNetworkMode();
const toursOn = useAddonStore((state) => state.isEnabled('tours')); const toursOn = useAddonStore((state) => state.isEnabled('tours'));
const list = useRegionList(toursOn, offline, t); const list = useRegionList(toursOn, offline, t);
const autoLoad = usePaddleAutoLoad(toursOn, offline);
const search = useCatalogSearch(offline, t); const search = useCatalogSearch(offline, t);
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [removing, setRemoving] = useState<PaddleRegion | null>(null); const [removing, setRemoving] = useState<PaddleRegion | null>(null);
@@ -146,6 +152,12 @@ export function usePaddleRegionsAdmin() {
[run, setQuery, t] [run, setQuery, t]
); );
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]); const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
/** A paddler's request let through: the region is added like any other. */
const approve = useCallback(
(id: string) => run(id, () => paddleRegionRepo.add(id), t('admin.paddleRegions.toast.added')),
[run, t]
);
const decline = useCallback((id: string) => run(id, () => paddleRegionRepo.decline(id)), [run]);
const confirmRemove = useCallback(() => { const confirmRemove = useCallback(() => {
const region = removing; const region = removing;
setRemoving(null); setRemoving(null);
@@ -157,6 +169,8 @@ export function usePaddleRegionsAdmin() {
toursOn, toursOn,
offline, offline,
regions: list.regions, regions: list.regions,
requests: list.requests,
autoLoad,
loading: list.loading, loading: list.loading,
error: list.error, error: list.error,
retry: reload, retry: reload,
@@ -165,6 +179,8 @@ export function usePaddleRegionsAdmin() {
busyId, busyId,
add, add,
refresh, refresh,
approve,
decline,
removing, removing,
askRemove: setRemoving, askRemove: setRemoving,
cancelRemove: () => setRemoving(null), cancelRemove: () => setRemoving(null),
@@ -24,7 +24,7 @@ export const CATEGORIES = [
{ id: 'members', keys: ['member_manage'] }, { id: 'members', keys: ['member_manage'] },
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] }, { id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] }, { id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] }, { id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
]; ];
/** /**
@@ -98,6 +98,19 @@ describe('JourneySettingsDialog', () => {
await screen.findByRole('button', { name: /create share link/i }) await screen.findByRole('button', { name: /create share link/i })
}) })
it('FE-JRN-SETTINGS-2683: lists the people a linked trip lets in under their own heading, and nothing without', () => {
const { unmount } = mountDialog()
expect(screen.queryByText('Through trips')).not.toBeInTheDocument()
unmount()
mountDialog(buildJourney({
trip_access: [{ user_id: 7, role: 'viewer', username: 'lena', avatar: null, trip_id: 5, trip_title: 'Italy Trip' }],
}))
expect(screen.getByText('Through trips')).toBeInTheDocument()
expect(screen.getByText('lena')).toBeInTheDocument()
expect(screen.getByText('via Italy Trip')).toBeInTheDocument()
})
it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => { it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const { onSaved } = mountDialog() const { onSaved } = mountDialog()
@@ -7,7 +7,7 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts' import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
import JourneyShareSection from './JourneyShareSection' import JourneyShareSection from './JourneyShareSection'
import { useJourneySettings } from './useJourneySettings' import { useJourneySettings } from './useJourneySettings'
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore' import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { AddTripDialog } from './JourneyDetailPageAddTripDialog' import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
import ToggleSwitch from '../Settings/ToggleSwitch' import ToggleSwitch from '../Settings/ToggleSwitch'
@@ -277,6 +277,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</div> </div>
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton> <AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
</DialogSection> </DialogSection>
<TripAccessSection access={journey.trip_access} roleLabel={roleLabel} />
</div> </div>
</div> </div>
@@ -324,3 +326,32 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</> </>
) )
} }
/**
* Who a trip's linked journal lets in (#2683), under its own heading below the
* contributors: shown here, but managed in that trip's settings, so read only.
*/
function TripAccessSection({ access, roleLabel }: {
access: JourneyTripAccess[] | undefined
roleLabel: (role: JourneyContributor['role']) => string
}) {
const { t } = useTranslation()
if (!access?.length) return null
return (
<DialogSection label={t('journey.contributors.viaTrips')}>
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
<div className={ROWS}>
{access.map(a => (
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
</div>
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
</div>
))}
</div>
</DialogSection>
)
}
+14 -18
View File
@@ -169,23 +169,24 @@ describe('Navbar', () => {
expect(adminLink).toHaveAttribute('href', '/admin'); expect(adminLink).toHaveAttribute('href', '/admin');
}); });
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => { it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
render(<Navbar onShare={vi.fn()} />); render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
const shareBtn = screen.getByRole('button', { name: /share/i }); const back = screen.getByRole('button', { name: /back/i });
expect(shareBtn).toBeInTheDocument(); const settings = screen.getByRole('button', { name: 'Trip settings' });
expect(back.parentElement).toBe(settings.closest('div'));
}); });
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => { it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onShare = vi.fn(); const onSettings = vi.fn();
render(<Navbar onShare={onShare} />); render(<Navbar onSettings={onSettings} />);
const shareBtn = screen.getByRole('button', { name: /share/i }); await user.click(screen.getByRole('button', { name: 'Trip settings' }));
await user.click(shareBtn); expect(onSettings).toHaveBeenCalled();
expect(onShare).toHaveBeenCalled();
}); });
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => { it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
render(<Navbar />); render(<Navbar />);
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
}); });
@@ -453,8 +454,8 @@ describe('Navbar styling and menu details', () => {
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument(); expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
}); });
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => { it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />); render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i }); const back = screen.getByRole('button', { name: /back/i });
fireEvent.mouseEnter(back); fireEvent.mouseEnter(back);
@@ -462,11 +463,6 @@ describe('Navbar styling and menu details', () => {
fireEvent.mouseLeave(back); fireEvent.mouseLeave(back);
expect(back.style.background).toBe('transparent'); expect(back.style.background).toBe('transparent');
const share = screen.getByRole('button', { name: /share/i });
fireEvent.mouseEnter(share);
expect(share.style.background).toBe('var(--bg-hover)');
fireEvent.mouseLeave(share);
expect(share.style.background).toBe('var(--bg-card)');
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement; const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
fireEvent.mouseEnter(theme); fireEvent.mouseEnter(theme);
+19 -14
View File
@@ -6,7 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore' import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react' import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx' import InAppNotificationBell from './InAppNotificationBell.tsx'
import HelpButton from '../Help/HelpButton' import HelpButton from '../Help/HelpButton'
@@ -21,7 +21,8 @@ interface NavbarProps {
tripId?: number | string tripId?: number | string
onBack?: () => void onBack?: () => void
showBack?: boolean showBack?: boolean
onShare?: () => void /** The trip's Settings: who is on it, its links and its journal. */
onSettings?: () => void
} }
interface Addon { interface Addon {
@@ -32,7 +33,7 @@ interface Addon {
enabled: boolean enabled: boolean
} }
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement { export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
const { user, logout, isPrerelease, appVersion } = useAuthStore() const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { settings, updateSetting } = useSettingsStore() const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore() const { addons: allAddons, loadAddons } = useAddonStore()
@@ -173,6 +174,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span> <span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
</span> </span>
))} ))}
{/* The trip's Settings sit with the trip, beside its pill: they are about this
trip, not about the app the right side of the bar belongs to. */}
{onSettings && (
// The same pill as the trip's own beside it, so the two read as one pair.
<button
type="button"
onClick={onSettings}
aria-label={t('trip.settings.title')}
className="inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card py-1 ps-2 pe-3 text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Settings className="h-4 w-4 flex-none text-content-muted" aria-hidden="true" />
<span className="text-caption font-semibold">{t('nav.settings')}</span>
</button>
)}
</div> </div>
{/* Centred liquid-glass tab menu (design handoff). {/* Centred liquid-glass tab menu (design handoff).
@@ -245,17 +261,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
itself on top of them. */} itself on top of them. */}
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end"> <div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
{/* Share button */}
{onShare && (
<button type="button" onClick={onShare}
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
<Users className="w-4 h-4" />
<span className="hidden sm:inline">{t('nav.share')}</span>
</button>
)}
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */} {/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
{user && <HelpButton />} {user && <HelpButton />}
+4 -11
View File
@@ -8,6 +8,7 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
import RoadtripViaMarkers from './RoadtripViaMarkers' import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker' import PlannerWaypointMarker from './PlannerWaypointMarker'
import HazardLayers from './HazardLayers' import HazardLayers from './HazardLayers'
import { RouteHitBand } from './RouteHitBand'
import { formatViaDwell } from './routeViaDwell' import { formatViaDwell } from './routeViaDwell'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors' import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker' import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
@@ -1158,8 +1159,7 @@ export const MapView = memo(function MapView({
</MarkerClusterGroup> </MarkerClusterGroup>
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. {/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
The casing carries the click when the route can be reshaped: it is the wider of When the route can be reshaped an invisible band wider than both takes the click. */}
the two, so it is the one a pointer actually lands on. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [ {route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
// Walked, so dashed and without the casing: the drive is the solid line (#2532). // Walked, so dashed and without the casing: the drive is the solid line (#2532).
<Polyline <Polyline
@@ -1173,15 +1173,7 @@ export const MapView = memo(function MapView({
key={`${i}-casing`} key={`${i}-casing`}
positions={seg} positions={seg}
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }} pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={!!onRouteClick} interactive={false}
eventHandlers={onRouteClick ? {
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu
// underneath the new via.
e.originalEvent.stopPropagation()
onRouteClick(e.latlng.lat, e.latlng.lng)
},
} : undefined}
/>, />,
<Polyline <Polyline
key={`${i}-core`} key={`${i}-core`}
@@ -1189,6 +1181,7 @@ export const MapView = memo(function MapView({
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }} pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false} interactive={false}
/>, />,
...(onRouteClick ? [<RouteHitBand key={`${i}-hit`} positions={seg} onRouteClick={onRouteClick} />] : []),
])} ])}
{/* The last bit to a place the road does not reach. {/* The last bit to a place the road does not reach.
@@ -2,18 +2,25 @@ import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet'; import { Marker, Tooltip, useMap } from 'react-leaflet';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel'; import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'; import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The pin's inline style for a size and selection; the renderer owns the colour. */ /** The pin's inline style for a size and selection; the renderer owns the colour. */
export type PinCss = (size: number, selected: boolean) => string; export type PinCss = (size: number, selected: boolean) => string;
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon { function plannerWaypointIcon(
const size = selected ? 30 : 26; pinCss: PinCss,
point: PlannerPinPoint,
index: number,
selected: boolean,
draggable: boolean
): L.DivIcon {
const { size, text } = plannerPinShape(point, index, selected);
return L.divIcon({ return L.divIcon({
className: 'tour-planner-waypoint-marker', className: 'tour-planner-waypoint-marker',
iconSize: [size, size], iconSize: [size, size],
iconAnchor: [size / 2, size / 2], iconAnchor: [size / 2, size / 2],
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`, html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>`,
}); });
} }
@@ -25,7 +32,7 @@ export default function PlannerWaypointMarker({
onMove, onMove,
pinCss, pinCss,
}: { }: {
point: { id: string; lat: number; lng: number; name?: string; detail?: string }; point: { id: string; lat: number; lng: number; name?: string; detail?: string } & PlannerPinPoint;
index: number; index: number;
selected: boolean; selected: boolean;
onClick?: (id: string) => void; onClick?: (id: string) => void;
@@ -39,7 +46,11 @@ export default function PlannerWaypointMarker({
handlersRef.current = { onClick, onMove }; handlersRef.current = { onClick, onMove };
}, [onClick, onMove]); }, [onClick, onMove]);
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]); const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]); const { label, shaping } = point;
const icon = useMemo(
() => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove),
[index, label, onMove, pinCss, selected, shaping]
);
const eventHandlers = useMemo( const eventHandlers = useMemo(
() => ({ () => ({
click: (event: { originalEvent: MouseEvent }) => { click: (event: { originalEvent: MouseEvent }) => {
@@ -0,0 +1,35 @@
import { Polyline } from 'react-leaflet';
/**
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
* than the drawn line, that takes the click which reshapes the route, as the
* GL renderer's hit layer does: an 8 px line is too thin to aim at.
*/
export function RouteHitBand({
positions,
onRouteClick,
}: {
positions: [number, number][];
onRouteClick: (lat: number, lng: number) => void;
}) {
return (
<Polyline
positions={positions}
pathOptions={{
color: 'transparent',
weight: 20,
opacity: 0,
lineCap: 'round',
lineJoin: 'round',
className: 'cursor-copy',
}}
eventHandlers={{
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
e.originalEvent.stopPropagation();
onRouteClick(e.latlng.lat, e.latlng.lng);
},
}}
/>
);
}
+9 -1
View File
@@ -110,7 +110,15 @@ export interface MapViewProps {
onHighlightAlternative?: (index: number | null) => void; onHighlightAlternative?: (index: number | null) => void;
/** Generic numbered control points for list-first route editors. */ /** Generic numbered control points for list-first route editors. */
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */ /** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>; plannerWaypoints?: Array<{
id: string;
lat: number;
lng: number;
name?: string;
detail?: string;
label?: string;
shaping?: boolean;
}>;
selectedPlannerWaypointId?: string | null; selectedPlannerWaypointId?: string | null;
onPlannerWaypointClick?: (id: string) => void; onPlannerWaypointClick?: (id: string) => void;
/** Drops a dragged control point; returns false when the move was refused. */ /** Drops a dragged control point; returns false when the move was refused. */
@@ -0,0 +1,12 @@
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
import { describe, expect, it } from 'vitest';
import { plannerPinShape } from './plannerPinShape';
describe('plannerPinShape', () => {
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
expect(plannerPinShape({}, 2, false)).toEqual({ size: 26, text: '3' });
expect(plannerPinShape({ label: '2' }, 4, true)).toEqual({ size: 30, text: '2' });
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
});
});
@@ -0,0 +1,16 @@
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
export interface PlannerPinPoint {
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
label?: string;
shaping?: boolean;
}
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
export function plannerPinShape(
point: PlannerPinPoint,
index: number,
selected: boolean
): { size: number; text: string } {
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
}
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel'; import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract'; import type { MapViewProps } from './mapViewContract';
import { plannerPinShape } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'; import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */ /** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
@@ -71,10 +72,10 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
for (const [index, point] of plannerWaypoints.entries()) { for (const [index, point] of plannerWaypoints.entries()) {
const selected = point.id === selectedPlannerWaypointId; const selected = point.id === selectedPlannerWaypointId;
const size = selected ? 30 : 26; const { size, text } = plannerPinShape(point, index, selected);
const el = document.createElement('button'); const el = document.createElement('button');
el.type = 'button'; el.type = 'button';
el.textContent = String(index + 1); el.textContent = text;
const name = point.name ?? `Waypoint ${index + 1}`; const name = point.name ?? `Waypoint ${index + 1}`;
el.setAttribute('aria-label', name); el.setAttribute('aria-label', name);
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`; el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
@@ -159,7 +159,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
) )
return ( return (
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" align="top" blocked={!!asking} header={header} footer={footer}> <DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" blocked={!!asking} header={header} footer={footer}>
<div ref={bodyRef}> <div ref={bodyRef}>
{asking ? ( {asking ? (
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter"> <section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
@@ -0,0 +1,36 @@
import type { PaddleLakeStyle } from '@trek/shared';
import { useTranslation } from '../../../i18n';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
const STYLES: readonly PaddleLakeStyle[] = ['direct', 'shore'];
/**
* PAD-11: how the route crosses a lake. Straight over is the rule; along the
* shore is for wind, when a capsize near the bank is a short swim. The wind
* card suggests it on a windy day; here it is chosen.
*/
export function PaddleLakeStyleField({
value,
onChange,
}: {
value: PaddleLakeStyle;
onChange: (style: PaddleLakeStyle) => void;
}) {
const { t } = useTranslation();
return (
<div data-testid="tour-planner-lake-style" className="min-w-0">
<span className={LABEL}>{t('tours.paddle.lakeStyle')}</span>
<Segmented<PaddleLakeStyle>
value={value}
onChange={onChange}
label={t('tours.paddle.lakeStyle')}
fill
options={STYLES.map((style) => ({ value: style, label: t(`tours.paddle.lakeStyle.${style}`) }))}
/>
<span className="mt-1 block text-content-faint" style={fs(11, 'body')}>
{t(`tours.paddle.lakeStyle.${value}Hint`)}
</span>
</div>
);
}
@@ -33,6 +33,8 @@ const PAINT: PaddlePaint = {
access: '#4338ca', access: '#4338ca',
landing: '#0f766e', landing: '#0f766e',
portage: '#9a6233', portage: '#9a6233',
rapids: '#d97706',
gap: '#dc2626',
disc: '#ffffff', disc: '#ffffff',
shadow: 'rgba(15, 23, 42, 0.35)', shadow: 'rgba(15, 23, 42, 0.35)',
}; };
@@ -0,0 +1,152 @@
import type { PaddleRouteLeg } from '@trek/shared';
import { AlertTriangle, RotateCcw, Sparkles, Spline, Waves } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import type { PaddleLegAnswer } from '../../../repo/paddleRouteRepo';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import type { PaddleLegFailure } from '../planner/paddleRouting';
import { lineLengthM } from '../planner/tourFixedLegs';
/**
* PAD-11: what the waypoint list says about a leg the router planned or
* could not plan, and about a point it set itself.
*/
/** A point the route planner set at a portage or a lock it found; touched by the paddler, it becomes theirs. */
export function AutoPointPill() {
const { t } = useTranslation();
// Only an icon: the row keeps its room for the place name, the tooltip says the rest.
return (
<Tooltip label={`${t('tours.paddle.auto')}: ${t('tours.paddle.autoHint')}`}>
<span
data-testid="tour-planner-auto-point"
aria-label={t('tours.paddle.auto')}
className="grid h-[18px] w-[18px] flex-none place-items-center rounded-full bg-surface-secondary text-content-muted"
>
<Sparkles size={10} strokeWidth={2.2} aria-hidden="true" />
</span>
</Tooltip>
);
}
type Note = { key: string; label: string; tone: 'rapids' | 'gap'; length: number };
/** The stretches of a routed leg worth a look: whitewater, a boat pass, a gap in the data. */
function notesOf(
leg: Extract<PaddleRouteLeg, { status: 'ok' }>,
t: (key: string, params?: Record<string, string | number>) => string
): Note[] {
return leg.segments.flatMap((segment, index): Note[] => {
const length = lineLengthM(leg.geometry.slice(segment.from, segment.to + 1) as [number, number][]);
if (segment.kind === 'rapids' && segment.grade != null) {
return [
{ key: `r${index}`, label: t('tours.paddle.note.rapids', { grade: segment.grade }), tone: 'rapids', length },
];
}
if (segment.kind === 'pass')
return [{ key: `p${index}`, label: segment.name ?? t('tours.paddle.note.pass'), tone: 'rapids', length }];
if (segment.kind === 'gap') return [{ key: `g${index}`, label: t('tours.paddle.note.gap'), tone: 'gap', length }];
return [];
});
}
/** Chips under a routed leg for what the paddler should look at on it; nothing when the leg is plain water. */
export function PaddleLegNotes({ leg }: { leg: PaddleLegAnswer | null | undefined }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
if (!leg || leg.status !== 'ok') return null;
const notes = notesOf(leg, t);
if (notes.length === 0) return null;
return (
<li data-testid="tour-planner-leg-notes" className="flex min-w-0 flex-wrap gap-1 py-0.5 ps-[34px]">
{notes.map((note) => (
<Tooltip
key={note.key}
label={t(note.tone === 'gap' ? 'tours.paddle.note.gapHint' : 'tours.paddle.note.rapidsHint')}
>
<span
className={`inline-flex h-[22px] min-w-0 items-center gap-1 rounded-full bg-surface-card pe-1 ps-1.5 font-semibold text-content shadow-sm ring-1 ring-inset ${note.tone === 'gap' ? 'ring-[color:var(--map-paddle-gap)]' : 'ring-[color:var(--map-paddle-rapids)]'}`}
style={fs(11)}
>
{note.tone === 'gap' ? (
<AlertTriangle
size={11}
strokeWidth={2.2}
className="flex-none"
style={{ color: 'var(--map-paddle-gap)' }}
aria-hidden="true"
/>
) : (
<Waves
size={11}
strokeWidth={2.2}
className="flex-none"
style={{ color: 'var(--map-paddle-rapids)' }}
aria-hidden="true"
/>
)}
<span className="min-w-0 truncate">{note.label}</span>
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-normal tabular-nums leading-[16px] text-content-secondary"
style={fs(10)}
>
{formatDistance(note.length / 1000, unit)}
</span>
</span>
</Tooltip>
))}
</li>
);
}
/**
* A leg the router could not plan: why, in plain words, and what to do about
* it. The map draws it straight and red meanwhile, and the tour waits to be
* saved until it is planned or drawn by hand on purpose. Where TREK could not
* fetch the water data of the leg's region (PAD-11C), `message` says why and
* `onLoadRetry` reads the region again.
*/
export function PaddleLegFailureRow({
code,
disabled,
onDrawStraight,
onRetry,
message,
onLoadRetry,
}: {
code: PaddleLegFailure;
disabled: boolean;
onDrawStraight: () => void;
onRetry: () => void;
message?: string | null;
onLoadRetry?: () => void;
}) {
const { t } = useTranslation();
const retry = onLoadRetry ?? (code !== 'not_on_water' && code !== 'no_region' ? onRetry : null);
const action =
'inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40';
return (
<li data-testid="tour-planner-leg-failure" className="flex min-w-0 py-0.5 ps-[34px]">
<div role="alert" className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-danger-soft px-2 py-1.5">
<span className="flex min-w-0 items-center gap-1.5 font-semibold text-danger" style={fs(11.5, 'body')}>
<AlertTriangle size={12} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
<span className="min-w-0">{message ?? t(`tours.paddle.failed.${code}`)}</span>
</span>
<span className="flex flex-wrap gap-1">
<button type="button" disabled={disabled} onClick={onDrawStraight} className={action} style={fs(10.5)}>
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.paddle.drawStraight')}
</button>
{retry && (
<button type="button" disabled={disabled} onClick={retry} className={action} style={fs(10.5)}>
<RotateCcw size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.paddle.retry')}
</button>
)}
</span>
</div>
</li>
);
}
@@ -0,0 +1,73 @@
// FE-PADDLE-PLACE-001 to 004: a put-in, slipway or egress set as the next waypoint from its card (PAD-05B).
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { PaddleObstaclePlaceButton } from './PaddleObstaclePlaceButton';
import { placeOf } from './paddleObstaclePlace';
describe('placeOf', () => {
it('FE-PADDLE-PLACE-001: takes a point as it is, the middle of a way and the middle of an outline', () => {
expect(placeOf({ type: 'Point', coordinates: [12.9, 53.2] })).toEqual({ lat: 53.2, lng: 12.9 });
expect(
placeOf({
type: 'LineString',
coordinates: [
[12.9, 53.2],
[12.91, 53.21],
[12.92, 53.22],
],
})
).toEqual({ lat: 53.21, lng: 12.91 });
const middle = placeOf({
type: 'Polygon',
coordinates: [
[
[12, 53],
[12.02, 53],
[12.02, 53.02],
[12, 53.02],
[12, 53],
],
],
});
expect(middle?.lat).toBeCloseTo(53.01, 9);
expect(middle?.lng).toBeCloseTo(12.01, 9);
});
});
describe('PaddleObstaclePlaceButton', () => {
it('FE-PADDLE-PLACE-002: sets a slipway as a put-in at its spot', () => {
const onPlace = vi.fn();
render(
<PaddleObstaclePlaceButton
kind="slipway"
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
onPlace={onPlace}
/>
);
fireEvent.click(screen.getByTestId('paddle-obstacle-place'));
expect(onPlace).toHaveBeenCalledWith(53.2, 12.9);
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Put in here');
});
it('FE-PADDLE-PLACE-003: names an egress as the place to get out', () => {
render(
<PaddleObstaclePlaceButton
kind="egress"
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
onPlace={vi.fn()}
/>
);
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Take out here');
});
it('FE-PADDLE-PLACE-004: offers nothing on an obstacle', () => {
render(
<PaddleObstaclePlaceButton
kind="weir"
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
onPlace={vi.fn()}
/>
);
expect(screen.queryByTestId('paddle-obstacle-place')).toBeNull();
});
});
@@ -0,0 +1,45 @@
import type { PaddleGeometry, PaddleObstacleKind } from '@trek/shared';
import { MapPinPlus } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { placeOf } from './paddleObstaclePlace';
/** Where a paddler gets in or out: these places can become a waypoint from their card. */
const PLACEABLE: Partial<Record<PaddleObstacleKind, 'put_in' | 'egress'>> = {
put_in: 'put_in',
slipway: 'put_in',
egress: 'egress',
};
/**
* PAD-05B: a put-in, a slipway or an egress taken over as the tour's next
* waypoint from its card on the map, so the point sits where the paddler
* really gets in or out. Set after a portage asked for at the last point, it
* is where the portage ends.
*/
export function PaddleObstaclePlaceButton({
kind,
geometry,
onPlace,
}: {
kind: PaddleObstacleKind;
geometry: PaddleGeometry;
onPlace: (lat: number, lng: number) => void;
}) {
const { t } = useTranslation();
const role = PLACEABLE[kind];
const spot = role ? placeOf(geometry) : null;
if (!role || !spot) return null;
return (
<button
type="button"
data-testid="paddle-obstacle-place"
onClick={() => onPlace(spot.lat, spot.lng)}
className="mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text transition-opacity hover:opacity-90"
style={fs(12, 'body')}
>
<MapPinPlus size={14} strokeWidth={2.2} aria-hidden="true" />
{t(role === 'put_in' ? 'tours.paddle.placeHere.putIn' : 'tours.paddle.placeHere.egress')}
</button>
);
}
@@ -0,0 +1,78 @@
// FE-PADDLE-REGION-LOAD-ROW-001 to 004: what a leg waiting for its water data shows (PAD-11C).
import type { PaddleRegionLoadState } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { PaddleRegionLoadRow } from './PaddleRegionLoadRow';
import { isRegionLoadPending, regionLoadFailure } from './paddleRegionLoad';
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
const t = (key: string, params?: Record<string, unknown>) => `${key}${params ? JSON.stringify(params) : ''}`;
describe('PaddleRegionLoadRow', () => {
it('FE-PADDLE-REGION-LOAD-ROW-001: says the data loads, how far, and that the leg follows on its own', () => {
const onDrawStraight = vi.fn();
render(
<PaddleRegionLoadRow
state={{ state: 'loading', region: REF, progress: { phase: 'download', fraction: 0.45 }, ahead: 0 }}
disabled={false}
onDrawStraight={onDrawStraight}
/>
);
const row = screen.getByTestId('tour-planner-region-load');
expect(row).toHaveTextContent('Loading the water data for Oberpfalz');
expect(row).toHaveTextContent('Downloading 45%');
fireEvent.click(screen.getByRole('button', { name: /Draw straight/ }));
expect(onDrawStraight).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-REGION-LOAD-ROW-002: says a region waits for an admin, and why', () => {
render(
<PaddleRegionLoadRow
state={{ state: 'requested', region: REF, reason: 'too_large' }}
disabled={false}
onDrawStraight={vi.fn()}
/>
);
const row = screen.getByTestId('tour-planner-region-load');
expect(row).toHaveTextContent('has been requested');
expect(row).toHaveTextContent('larger than TREK loads by itself');
});
it('FE-PADDLE-REGION-LOAD-ROW-003: shows the queue before the read starts, and no button once the data is there', () => {
const { unmount } = render(
<PaddleRegionLoadRow
state={{ state: 'loading', region: REF, progress: null, ahead: 2 }}
disabled={false}
onDrawStraight={vi.fn()}
/>
);
expect(screen.getByTestId('tour-planner-region-load')).toHaveTextContent('Waiting for other regions');
unmount();
render(<PaddleRegionLoadRow state={{ state: 'ready', region: REF }} disabled={false} onDrawStraight={vi.fn()} />);
expect(screen.queryByRole('button')).toBeNull();
});
});
describe('region load states', () => {
it('FE-PADDLE-REGION-LOAD-ROW-004: waits on loading and requests, and names every way the data cannot come', () => {
const pending: PaddleRegionLoadState[] = [
{ state: 'ready', region: REF },
{ state: 'requested', region: REF, reason: 'ask' },
];
for (const state of pending) expect(isRegionLoadPending(state)).toBe(true);
expect(isRegionLoadPending(undefined)).toBe(false);
expect(regionLoadFailure({ state: 'declined', region: REF }, t)).toBe(
'tours.paddle.load.declined{"region":"Oberpfalz"}'
);
expect(regionLoadFailure({ state: 'failed', region: REF }, t)).toContain('tours.paddle.load.failed');
expect(regionLoadFailure({ state: 'no_water', region: REF }, t)).toContain('tours.paddle.load.noWater');
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'no_extract' }, t)).toBe(
'tours.paddle.load.noExtract'
);
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'catalog_unavailable' }, t)).toBe(
'tours.paddle.load.catalogUnavailable'
);
expect(regionLoadFailure({ state: 'unavailable', region: REF, reason: 'off' }, t)).toBeNull();
expect(regionLoadFailure(undefined, t)).toBeNull();
});
});
@@ -0,0 +1,99 @@
import type { PaddleRegionLoadState } from '@trek/shared';
import { CloudDownload, Hourglass, Loader2, Spline } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
type Translate = ReturnType<typeof useTranslation>['t'];
function headline(state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>, t: Translate) {
const region = state.region.name;
if (state.state === 'ready') return t('tours.paddle.load.ready', { region });
if (state.state === 'requested') return t('tours.paddle.load.requested', { region });
return t('tours.paddle.load.loading', { region });
}
/** The phase of a load in words, with its share where it is known. */
function phaseText(state: Extract<PaddleRegionLoadState, { state: 'loading' }>, t: Translate, language: string) {
if (!state.progress) return state.ahead > 0 ? t('tours.paddle.load.waiting') : t('tours.paddle.load.phase.download');
const phase = t(`tours.paddle.load.phase.${state.progress.phase}`);
const share = state.progress.fraction;
return share === null ? phase : `${phase} ${new Intl.NumberFormat(language, { style: 'percent' }).format(share)}`;
}
/**
* PAD-11C: a leg waiting for the water data of its region, which TREK loads
* by itself or an admin has to let through. Calm, not red: nothing is wrong,
* the data is on its way, and the leg is planned on its own once it is there.
* Drawing it straight stays possible for whoever does not want to wait.
*/
export function PaddleRegionLoadRow({
state,
disabled,
onDrawStraight,
}: {
state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>;
disabled: boolean;
onDrawStraight: () => void;
}) {
const { t, language } = useTranslation();
const fraction = state.state === 'loading' ? (state.progress?.fraction ?? null) : state.state === 'ready' ? 1 : null;
const busy = state.state !== 'requested';
return (
<li data-testid="tour-planner-region-load" className="flex min-w-0 py-0.5 ps-[34px]">
<div
role="status"
className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-surface-secondary px-2 py-1.5 ring-1 ring-inset ring-edge-faint"
>
<span className="flex min-w-0 items-start gap-1.5 font-semibold text-content" style={fs(11.5, 'body')}>
{busy ? (
<Loader2 size={12} strokeWidth={2.2} className="mt-[2px] flex-none animate-spin" aria-hidden="true" />
) : (
<Hourglass size={12} strokeWidth={2.2} className="mt-[2px] flex-none" aria-hidden="true" />
)}
<span className="min-w-0">{headline(state, t)}</span>
</span>
{state.state === 'loading' && (
<span className="flex min-w-0 flex-col gap-1">
<span className="flex items-center justify-between gap-2 text-content-muted" style={fs(10.5, 'body')}>
<span className="inline-flex min-w-0 items-center gap-1">
<CloudDownload size={11} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
<span className="truncate">{phaseText(state, t, language)}</span>
</span>
</span>
<span
className="relative block h-[3px] w-full overflow-hidden rounded-full bg-surface-tertiary"
aria-hidden="true"
>
<span
className={`absolute inset-y-0 start-0 rounded-full bg-accent transition-[width] duration-500 ${fraction === null ? 'w-1/3 animate-pulse' : ''}`}
style={fraction === null ? undefined : { width: `${Math.max(4, Math.round(fraction * 100))}%` }}
/>
</span>
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.load.hint')}
</span>
</span>
)}
{state.state === 'requested' && state.reason !== 'ask' && (
<span className="leading-snug text-content-muted" style={fs(10.5, 'body')}>
{t(state.reason === 'too_large' ? 'tours.paddle.load.reason.tooLarge' : 'tours.paddle.load.reason.limit')}
</span>
)}
{state.state !== 'ready' && (
<span className="flex flex-wrap gap-1">
<button
type="button"
disabled={disabled}
onClick={onDrawStraight}
className="inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40"
style={fs(10.5)}
>
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.paddle.drawStraight')}
</button>
</span>
)}
</div>
</li>
);
}
@@ -7,8 +7,9 @@ import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub'; import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
import { useSettingsStore } from '../../../store/settingsStore'; import { useSettingsStore } from '../../../store/settingsStore';
import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf'; import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { portageLegBetween } from '../planner/tourRouteActions'; import { portageLegBetween } from '../planner/tourRouteActions';
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner'; import type { TourPlannerController } from '../planner/useTourPlanner';
import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard'; import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard';
/** What the card writes for a time it cannot give: an en dash. */ /** What the card writes for a time it cannot give: an en dash. */
@@ -0,0 +1,53 @@
// FE-PADDLE-WAYPOINT-PARTS-001 to 004: the pending portage at the last point and a portage's way on land (PAD-03).
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { PendingPortageRow, PortageLegRow, type PortageWay } from './PaddleWaypointParts';
const way = (extra: Partial<PortageWay> = {}): PortageWay => ({
routed: false,
noWay: false,
searching: false,
onFollow: vi.fn(),
onStraighten: vi.fn(),
...extra,
});
function openPortage(props: Partial<Parameters<typeof PortageLegRow>[0]> = {}) {
render(<PortageLegRow lengthM={320} extraMinutes={null} disabled={false} onExtraChange={vi.fn()} {...props} />);
fireEvent.click(screen.getByRole('button', { expanded: false }));
}
describe('PendingPortageRow', () => {
it('FE-PADDLE-WAYPOINT-PARTS-001: says to set the point where the boat goes in again, and takes the ask back', () => {
const onRemove = vi.fn();
render(<PendingPortageRow disabled={false} onRemove={onRemove} />);
expect(screen.getByTestId('tour-planner-portage-pending')).toHaveTextContent('Portage from here');
fireEvent.click(screen.getByRole('button', { name: 'Do not carry after all' }));
expect(onRemove).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-WAYPOINT-PARTS-002: offers no way back on a tour that cannot be edited', () => {
render(<PendingPortageRow disabled onRemove={vi.fn()} />);
expect(screen.queryByRole('button', { name: 'Do not carry after all' })).toBeNull();
});
});
describe('PortageLegRow way', () => {
it('FE-PADDLE-WAYPOINT-PARTS-003: switches between following the path and a straight line', () => {
const straight = way();
openPortage({ way: straight });
fireEvent.click(screen.getByRole('button', { name: 'Follow the path' }));
expect(straight.onFollow).toHaveBeenCalledTimes(1);
const routed = way({ routed: true });
openPortage({ way: routed });
fireEvent.click(screen.getAllByRole('button', { name: 'Straight line' })[1]);
expect(routed.onStraighten).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-WAYPOINT-PARTS-004: says why a carry stayed straight, and that the router is asked', () => {
openPortage({ way: way({ noWay: true }) });
expect(screen.getByTestId('tour-planner-portage-way')).toHaveTextContent('No path without a long detour');
openPortage({ way: way({ searching: true }) });
expect(screen.getAllByTestId('tour-planner-portage-way')[1]).toHaveTextContent('Looking for a path');
});
});
@@ -1,9 +1,10 @@
import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared'; import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared';
import { ChevronDown, X } from 'lucide-react'; import { ChevronDown, Loader2, X } from 'lucide-react';
import { useCallback, useState, type ReactNode } from 'react'; import { useCallback, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n'; import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore'; import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units'; import { formatDistance } from '../../../utils/units';
import { Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell'; import { fs } from '../../shared/DialogShell';
import { PaddleGlyph } from './PaddleGlyph'; import { PaddleGlyph } from './PaddleGlyph';
import { PORTAGE_CSS_COLOR } from './paddlePaint'; import { PORTAGE_CSS_COLOR } from './paddlePaint';
@@ -199,6 +200,99 @@ function StopCard({
); );
} }
/**
* A portage asked for at the last point, before the point it leads to is set:
* dashed in the portage brown, it says where to click next, and can be taken
* back. Once the next point is set it becomes a portage like any other.
*/
export function PendingPortageRow({ disabled, onRemove }: { disabled: boolean; onRemove: () => void }) {
const { t } = useTranslation();
return (
<li data-testid="tour-planner-portage-pending" className="flex min-w-0 py-0.5 ps-[34px]">
<div
className="flex w-full min-w-0 items-start gap-1.5 rounded-[14px] border border-dashed bg-surface-card py-1 pe-1 ps-1"
style={{ borderColor: PORTAGE_CSS_COLOR }}
>
<span
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full text-surface-card"
style={{ background: PORTAGE_CSS_COLOR }}
>
<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />
</span>
<span className="flex min-w-0 flex-1 flex-col py-[2px]">
<span className="truncate font-semibold text-content" style={fs(11.5, 'body')}>
{t('tours.paddle.portagePending')}
</span>
<span className="leading-snug text-content-muted" style={fs(11, 'body')}>
{t('tours.paddle.portagePendingHint')}
</span>
</span>
{!disabled && (
<button
type="button"
onClick={onRemove}
aria-label={t('tours.paddle.portagePendingOff')}
title={t('tours.paddle.portagePendingOff')}
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
>
<X size={12} strokeWidth={2.4} />
</button>
)}
</div>
</li>
);
}
export interface PortageWay {
/** The portage follows the ways on land (its line has a shape of its own). */
routed: boolean;
/** The router was asked and found no way without a big detour. */
noWay: boolean;
/** The router is being asked right now. */
searching: boolean;
onFollow: () => void;
onStraighten: () => void;
}
/** Whether a portage follows the ways on land or goes straight, and why it stayed straight. */
function PortageWayField({ way, disabled }: { way: PortageWay; disabled: boolean }) {
const { t } = useTranslation();
const value = way.routed ? 'follow' : 'straight';
const hint = way.searching
? t('tours.paddle.portageWay.searching')
: !way.routed && way.noWay
? t('tours.paddle.portageWay.noWay')
: null;
return (
<div className="flex min-w-0 flex-col gap-1" data-testid="tour-planner-portage-way">
<span className="block truncate text-content" style={fs(12, 'body')}>
{t('tours.paddle.portageWay')}
</span>
<fieldset disabled={disabled || way.searching} className="m-0 min-w-0 border-0 p-0">
<Segmented<'follow' | 'straight'>
value={value}
fill
label={t('tours.paddle.portageWay')}
onChange={(next) => (next === 'follow' ? way.onFollow() : way.onStraighten())}
options={[
{
value: 'follow',
label: t('tours.paddle.portageWay.follow'),
icon: way.searching ? <Loader2 size={12} className="animate-spin" aria-hidden="true" /> : undefined,
},
{ value: 'straight', label: t('tours.paddle.portageWay.straight') },
]}
/>
</fieldset>
{hint && (
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
{hint}
</span>
)}
</div>
);
}
/** /**
* A portage under the point it starts at, in the earth brown the map draws it * A portage under the point it starts at, in the earth brown the map draws it
* in: its length on land and the time for getting out, unloading and back in * in: its length on land and the time for getting out, unloading and back in
@@ -211,6 +305,7 @@ export function PortageLegRow({
onExtraChange, onExtraChange,
position, position,
onRemove, onRemove,
way,
}: { }: {
lengthM: number; lengthM: number;
extraMinutes: number | null; extraMinutes: number | null;
@@ -220,6 +315,8 @@ export function PortageLegRow({
position?: string; position?: string;
/** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */ /** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */
onRemove?: () => void; onRemove?: () => void;
/** On a planned tour: whether the portage follows the ways on land or goes straight, and the switch between them. */
way?: PortageWay;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit); const unit = useSettingsStore((state) => state.settings.distance_unit);
@@ -242,6 +339,7 @@ export function PortageLegRow({
{t('tours.paddle.portageInfo')} {t('tours.paddle.portageInfo')}
</p> </p>
{onRemove && !disabled && <RemoveStop label={t('tours.paddle.portageRemove')} onRemove={onRemove} />} {onRemove && !disabled && <RemoveStop label={t('tours.paddle.portageRemove')} onRemove={onRemove} />}
{way && <PortageWayField way={way} disabled={disabled} />}
<StopField <StopField
label={t('tours.paddle.portageExtra')} label={t('tours.paddle.portageExtra')}
hint={extraMinutes === null ? t('tours.paddle.startValue') : undefined} hint={extraMinutes === null ? t('tours.paddle.startValue') : undefined}
@@ -1,4 +1,4 @@
// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-016: the wind card of a paddle tour (PAD-08) and the section that feeds it. // FE-PADDLE-WIND-001 to FE-PADDLE-WIND-018: the wind card of a paddle tour (PAD-08) and the section that feeds it.
// FE-PADDLE-FIGURE-001 to FE-PADDLE-FIGURE-002: the figure badge the card shows its values in. // FE-PADDLE-FIGURE-001 to FE-PADDLE-FIGURE-002: the figure badge the card shows its values in.
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -340,6 +340,30 @@ describe('PaddleWindSection', () => {
}); });
}); });
describe('PaddleWindCard on the lakes (PAD-11)', () => {
it('FE-PADDLE-WIND-017: on a windy day offers to keep near the shore, and says so once the shore is chosen', () => {
const onChoose = vi.fn();
const windy = windOf(ready(forecastOf({ hours: [hourOf(12, 22, 34, 270)] })));
const { rerender } = render(<PaddleWindCard wind={windy} lakes={{ value: 'direct', onChoose }} />);
const advice = screen.getByTestId('paddle-wind-shore-advice');
expect(advice).toHaveTextContent('Up to 22 km/h, gusts 34 km/h on this day.');
fireEvent.click(within(advice).getByRole('button', { name: 'Plan near the shore' }));
expect(onChoose).toHaveBeenCalledWith('shore');
rerender(<PaddleWindCard wind={windy} lakes={{ value: 'shore', onChoose }} />);
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
expect(screen.getByText('The lakes are planned near the shore.')).toBeInTheDocument();
});
it('FE-PADDLE-WIND-018: says nothing of the shore on a calm day, nor where no lake is crossed', () => {
const calm = windOf(ready(forecastOf({ hours: [hourOf(12, 8, 14, 270)] })));
const { rerender } = render(<PaddleWindCard wind={calm} lakes={{ value: 'direct', onChoose: vi.fn() }} />);
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
rerender(<PaddleWindCard wind={windOf(ready(forecastOf({ hours: [hourOf(12, 30, 50, 270)] })))} />);
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
});
});
describe('FigureBadge', () => { describe('FigureBadge', () => {
it('FE-PADDLE-FIGURE-001: holds a label and a value; the warning tone colours the value alone', () => { it('FE-PADDLE-FIGURE-001: holds a label and a value; the warning tone colours the value alone', () => {
const { rerender } = render(<FigureBadge testId="figure" label="Height" value="1.2 m" tone="warning" />); const { rerender } = render(<FigureBadge testId="figure" label="Height" value="1.2 m" tone="warning" />);
@@ -1,4 +1,4 @@
import { compassPointOf, type WindForecastResponse, type WindHour } from '@trek/shared'; import { compassPointOf, type PaddleLakeStyle, type WindForecastResponse, type WindHour } from '@trek/shared';
import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react'; import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from '../../../i18n'; import { useTranslation } from '../../../i18n';
@@ -8,6 +8,7 @@ import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip'; import { Tooltip } from '../../shared/Tooltip';
import { RailCard, TourNotice } from '../tourParts'; import { RailCard, TourNotice } from '../tourParts';
import { FigureBadge } from './FigureBadge'; import { FigureBadge } from './FigureBadge';
import { windForShore } from './lakeStyleAdvice';
import { useTourWind, type TourWind } from './useTourWind'; import { useTourWind, type TourWind } from './useTourWind';
type Translate = (key: string, params?: Record<string, unknown>) => string; type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -209,15 +210,58 @@ function WindHints({ t }: { t: Translate }) {
); );
} }
/** The lake style of the tour being edited, for the wind card to suggest the shore on a windy day. */
export interface LakeStyleChoice {
value: PaddleLakeStyle;
onChoose: (style: PaddleLakeStyle) => void;
}
/**
* PAD-11: on a windy day, the offer to keep near the shores on the lakes,
* with the wind that prompts it; once taken, it says the lakes are planned so.
*/
function ShoreAdvice({
wind,
lakes,
t,
}: {
wind: { meanKmh: number; gustsKmh: number };
lakes: LakeStyleChoice;
t: Translate;
}) {
if (lakes.value === 'shore') {
return (
<TourNotice role="status" icon={<Wind size={13} strokeWidth={2} />}>
{t('tours.paddle.wind.shoreTaken')}
</TourNotice>
);
}
return (
<TourNotice tone="warning" icon={<Wind size={13} strokeWidth={2} />}>
<span data-testid="paddle-wind-shore-advice" className="flex flex-wrap items-center gap-x-2 gap-y-1">
{t('tours.paddle.wind.shoreAdvice', { mean: wind.meanKmh, gusts: wind.gustsKmh })}
<button
type="button"
onClick={() => lakes.onChoose('shore')}
className="font-semibold underline-offset-2 hover:underline"
>
{t('tours.paddle.wind.shoreAdopt')}
</button>
</span>
</TourNotice>
);
}
/** /**
* PAD-08: the wind on the day a paddle tour is planned for, at its start. The * PAD-08: the wind on the day a paddle tour is planned for, at its start. The
* mean, the gusts and the direction are figures of their own, with the source * mean, the gusts and the direction are figures of their own, with the source
* and when it was asked; no value is turned into a verdict, and missing data * and when it was asked; no value is turned into a verdict, and missing data
* is never shown as calm. * is never shown as calm.
*/ */
export function PaddleWindCard({ wind }: { wind: TourWind }) { export function PaddleWindCard({ wind, lakes }: { wind: TourWind; lakes?: LakeStyleChoice }) {
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null; const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null;
const shoreWind = ready && lakes ? windForShore(ready) : null;
return ( return (
<RailCard <RailCard
icon={<Wind size={14} strokeWidth={2} />} icon={<Wind size={14} strokeWidth={2} />}
@@ -249,6 +293,7 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) {
) : ( ) : (
<Unavailable wind={wind} t={t} locale={locale} /> <Unavailable wind={wind} t={t} locale={locale} />
)} )}
{shoreWind && lakes && <ShoreAdvice wind={shoreWind} lakes={lakes} t={t} />}
<WindHints t={t} /> <WindHints t={t} />
</div> </div>
</RailCard> </RailCard>
@@ -259,10 +304,13 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) {
export function PaddleWindSection({ export function PaddleWindSection({
placeId, placeId,
start, start,
lakes,
}: { }: {
placeId: number | null; placeId: number | null;
start: { lat: number; lng: number } | null; start: { lat: number; lng: number } | null;
/** Given while the tour is edited and its route crosses a lake: the card may suggest the shore. */
lakes?: LakeStyleChoice;
}) { }) {
const wind = useTourWind(placeId, start); const wind = useTourWind(placeId, start);
return <PaddleWindCard wind={wind} />; return <PaddleWindCard wind={wind} lakes={lakes} />;
} }
@@ -0,0 +1,51 @@
// FE-PADDLE-LAKE-ADVICE-001 to FE-PADDLE-LAKE-ADVICE-002: when the wind makes the shore worth suggesting (PAD-11 with PAD-08).
import type { WindForecastResponse, WindHour } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { crossesLake, windForShore } from './lakeStyleAdvice';
function forecast(hours: Array<Partial<WindHour> & { hour: number }>): WindForecastResponse {
return {
status: 'forecast',
date: '2026-06-01',
lat: 53.4,
lng: 12.7,
timezone: 'Europe/Berlin',
source: 'Open-Meteo',
fetched_at: '2026-05-30T08:00:00Z',
horizon_days: 16,
day: null,
hours: hours.map((hour) => ({ speed_kmh: null, gusts_kmh: null, direction_deg: null, ...hour })),
} as WindForecastResponse;
}
describe('lakeStyleAdvice', () => {
it('FE-PADDLE-LAKE-ADVICE-001: a fresh breeze or strong gusts in the paddling hours suggest the shore; a stormy night does not', () => {
expect(windForShore(forecast([{ hour: 10, speed_kmh: 12, gusts_kmh: 22 }]))).toBeNull();
expect(windForShore(forecast([{ hour: 14, speed_kmh: 21.4, gusts_kmh: 26 }]))).toEqual({
meanKmh: 21,
gustsKmh: 26,
});
expect(windForShore(forecast([{ hour: 14, speed_kmh: 15, gusts_kmh: 31 }]))).toEqual({ meanKmh: 15, gustsKmh: 31 });
expect(windForShore(forecast([{ hour: 3, speed_kmh: 40, gusts_kmh: 70 }]))).toBeNull();
// An hour without a value is no calm hour, but it is no wind either.
expect(windForShore(forecast([{ hour: 12 }]))).toBeNull();
});
it('FE-PADDLE-LAKE-ADVICE-002: only a route that crosses a lake makes the lake style matter', () => {
const lake = { kind: 'lake' as const, from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null };
const leg = (kind: 'lake' | 'water') => ({
status: 'ok' as const,
geometry: [
[53.4, 12.7],
[53.5, 12.7],
] as [number, number][],
segments: [{ ...lake, kind }],
distance_m: 1000,
snapped_m: { from: 0, to: 0 },
});
expect(crossesLake(null)).toBe(false);
expect(crossesLake({ legs: [leg('water'), null] })).toBe(false);
expect(crossesLake({ legs: [leg('water'), leg('lake')] })).toBe(true);
expect(crossesLake({ legs: [{ status: 'failed', code: 'offline', at: null }] })).toBe(false);
});
});
@@ -0,0 +1,32 @@
import type { WindForecastResponse } from '@trek/shared';
import type { PaddleTourRoute } from '../planner/paddleRouting';
/** The hours a day tour is out on the water, as the wind strip shows them. */
const FIRST_HOUR = 6;
const LAST_HOUR = 21;
/**
* Wind from which open water gets rough for a small boat: a moderate breeze
* (Beaufort 4 from 20 km/h) or gusts of 30 km/h, where a crossing turns into
* work and a capsize far from the bank into a long swim.
*/
const MEAN_KMH = 20;
const GUSTS_KMH = 30;
/** Whether the route as planned crosses a lake, so its lake style changes anything. */
export function crossesLake(route: Pick<PaddleTourRoute, 'legs'> | null): boolean {
return !!route?.legs.some((leg) => leg?.status === 'ok' && leg.segments.some((segment) => segment.kind === 'lake'));
}
/**
* PAD-11 with PAD-08: the strongest wind of the paddling hours when it is
* enough to keep near the shores on the lakes, else null. Lakes are crossed
* straight over as a rule and along the shore only in wind; this says when.
*/
export function windForShore(forecast: WindForecastResponse): { meanKmh: number; gustsKmh: number } | null {
const hours = forecast.hours.filter((hour) => hour.hour >= FIRST_HOUR && hour.hour <= LAST_HOUR);
const meanKmh = Math.max(0, ...hours.map((hour) => hour.speed_kmh ?? 0));
const gustsKmh = Math.max(0, ...hours.map((hour) => hour.gusts_kmh ?? 0));
if (meanKmh < MEAN_KMH && gustsKmh < GUSTS_KMH) return null;
return { meanKmh: Math.round(meanKmh), gustsKmh: Math.round(gustsKmh) };
}
@@ -34,6 +34,8 @@ const PAINT: PaddlePaint = {
access: '#4338ca', access: '#4338ca',
landing: '#0f766e', landing: '#0f766e',
portage: '#9a6233', portage: '#9a6233',
rapids: '#d97706',
gap: '#dc2626',
disc: '#ffffff', disc: '#ffffff',
shadow: 'rgba(15, 23, 42, 0.35)', shadow: 'rgba(15, 23, 42, 0.35)',
}; };
@@ -0,0 +1,23 @@
import type { PaddleGeometry } from '@trek/shared';
/**
* The spot a waypoint goes to: the place itself, the middle of a way (a
* slipway drawn as a ramp), or the middle of an area's outline.
*/
export function placeOf(geometry: PaddleGeometry): { lat: number; lng: number } | null {
const line =
geometry.type === 'Point'
? [geometry.coordinates]
: geometry.type === 'LineString'
? geometry.coordinates
: geometry.coordinates[0];
if (!line?.length) return null;
if (geometry.type === 'Polygon') {
const ring = line.slice(0, -1);
const lng = ring.reduce((sum, [x]) => sum + x, 0) / ring.length;
const lat = ring.reduce((sum, [, y]) => sum + y, 0) / ring.length;
return { lat, lng };
}
const [lng, lat] = line[Math.floor((line.length - 1) / 2)] ?? [];
return lat === undefined || lng === undefined ? null : { lat, lng };
}
@@ -12,6 +12,8 @@ const PADDLE_PAINT_TOKENS = {
access: '--map-paddle-access', access: '--map-paddle-access',
landing: '--map-paddle-landing', landing: '--map-paddle-landing',
portage: '--map-portage', portage: '--map-portage',
rapids: '--map-paddle-rapids',
gap: '--map-paddle-gap',
disc: '--map-paddle-disc', disc: '--map-paddle-disc',
shadow: '--map-paddle-shadow', shadow: '--map-paddle-shadow',
} as const; } as const;
@@ -0,0 +1,35 @@
import type { PaddleRegionLoadState } from '@trek/shared';
import type { useTranslation } from '../../../i18n';
type Translate = ReturnType<typeof useTranslation>['t'];
/** The states in which the data is on its way: the leg waits rather than fails. */
export function isRegionLoadPending(
state: PaddleRegionLoadState | undefined
): state is Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }> {
return state?.state === 'loading' || state?.state === 'ready' || state?.state === 'requested';
}
/**
* Why a leg stays unplanned once the data cannot come: declined, failed, no
* water near the place, nothing to load. Null where the leg's own reason says
* it best (loading is off, or nothing was asked yet).
*/
export function regionLoadFailure(state: PaddleRegionLoadState | undefined, t: Translate): string | null {
if (!state) return null;
const region = state.region?.name ?? '';
switch (state.state) {
case 'declined':
return t('tours.paddle.load.declined', { region });
case 'failed':
return t('tours.paddle.load.failed', { region });
case 'no_water':
return t('tours.paddle.load.noWater', { region });
case 'unavailable':
if (state.reason === 'no_extract') return t('tours.paddle.load.noExtract');
if (state.reason === 'catalog_unavailable') return t('tours.paddle.load.catalogUnavailable');
return null;
default:
return null;
}
}
@@ -0,0 +1,54 @@
// FE-PORTAGE-ROUTE-001 to 004: a portage laid on the ways on land, never on a detour (PAD-03).
import { beforeEach, describe, expect, it, vi } from 'vitest';
const valhallaRun = vi.fn();
vi.mock('../../Map/valhallaRoute', () => ({ valhallaRun: (...args: unknown[]) => valhallaRun(...args) }));
const { routePortageLine } = await import('./portageRoute');
const FROM = { lat: 53.2232, lng: 12.9668 };
const TO = { lat: 53.2229, lng: 12.9701 };
function answer(coordinates: [number, number][]) {
return { legs: [], snapped: [], total: { coordinates, distance: 0, duration: 0 } };
}
describe('routePortageLine', () => {
beforeEach(() => valhallaRun.mockReset());
it('FE-PORTAGE-ROUTE-001: asks the walking router on easy ground and starts and ends at the points themselves', async () => {
valhallaRun.mockResolvedValue(
answer([
[53.2233, 12.9672],
[53.2226, 12.9686],
[53.223, 12.9698],
])
);
const line = await routePortageLine(FROM, TO);
expect(valhallaRun).toHaveBeenCalledWith([FROM, TO], 'walking', [], undefined, { max_hiking_difficulty: 1 });
expect(line?.[0]).toEqual([FROM.lat, FROM.lng]);
expect(line?.[line.length - 1]).toEqual([TO.lat, TO.lng]);
expect(line).toHaveLength(5);
});
it('FE-PORTAGE-ROUTE-002: keeps the carry straight when the way is a long detour', async () => {
valhallaRun.mockResolvedValue(
answer([
[53.2233, 12.9672],
[53.24, 12.968],
[53.223, 12.9698],
])
);
expect(await routePortageLine(FROM, TO)).toBeNull();
});
it('FE-PORTAGE-ROUTE-003: keeps the carry straight when no router answers', async () => {
valhallaRun.mockResolvedValue(null);
expect(await routePortageLine(FROM, TO)).toBeNull();
});
it('FE-PORTAGE-ROUTE-004: takes no line of a single point', async () => {
valhallaRun.mockResolvedValue(answer([[53.2233, 12.9672]]));
expect(await routePortageLine(FROM, TO)).toBeNull();
});
});
@@ -0,0 +1,36 @@
import { haversineKm } from '../../../utils/geo';
import { valhallaRun } from '../../Map/valhallaRoute';
import { lineLengthM } from '../planner/tourFixedLegs';
type Point = { lat: number; lng: number };
/**
* How much longer than the straight line a carry along the ways may be before
* it counts as a detour: a way round a fenced lock is fine, a walk round the
* whole lake is not, and then the straight line says more.
*/
const DETOUR = { factor: 2.5, extraM: 300 } as const;
/** Paths a paddler carries a boat on: plain hiking ground, never a scramble. */
const CARRY_DIFFICULTY = 1;
/**
* PAD-03: the way a portage takes on land, from where the boat leaves the
* water to where it goes in again, on the ways a walker would take (the
* instance's Valhalla, the router the hikes use). The line starts and ends at
* the two points themselves, so the steps from the bank onto the path are
* part of it. Null when no router answers, or when the way is a detour.
*/
export async function routePortageLine(
from: Point,
to: Point,
signal?: AbortSignal
): Promise<[number, number][] | null> {
// The router answers null, never throws, when it cannot be reached; the carry then stays straight.
const run = await valhallaRun([from, to], 'walking', [], signal, { max_hiking_difficulty: CARRY_DIFFICULTY });
if (!run || run.total.coordinates.length < 2) return null;
const line: [number, number][] = [[from.lat, from.lng], ...run.total.coordinates, [to.lat, to.lng]];
const straightM = haversineKm(from, to) * 1000;
if (lineLengthM(line) > Math.max(straightM * DETOUR.factor, straightM + DETOUR.extraM)) return null;
return line;
}
@@ -37,7 +37,8 @@ import {
selectPaddleObstacle, selectPaddleObstacle,
usePaddleObstacleStore, usePaddleObstacleStore,
} from '../../../store/paddleObstacleStore'; } from '../../../store/paddleObstacleStore';
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner'; import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { usePaddleChecklist } from './usePaddleChecklist'; import { usePaddleChecklist } from './usePaddleChecklist';
type CheckRead = Awaited<ReturnType<typeof tourRepo.obstacleCheck>>; type CheckRead = Awaited<ReturnType<typeof tourRepo.obstacleCheck>>;
@@ -34,6 +34,8 @@ const PAINT: PaddlePaint = {
access: '#4338ca', access: '#4338ca',
landing: '#0f766e', landing: '#0f766e',
portage: '#9a6233', portage: '#9a6233',
rapids: '#d97706',
gap: '#dc2626',
disc: '#ffffff', disc: '#ffffff',
shadow: 'rgba(15, 23, 42, 0.35)', shadow: 'rgba(15, 23, 42, 0.35)',
}; };
@@ -0,0 +1,86 @@
// FE-PADDLE-REGION-LOAD-001 to 005: the planner fetches the water data of a place no region covers (PAD-11C).
import { act, renderHook, waitFor } from '@testing-library/react';
import type { PaddleRegionLoadState } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const load = vi.fn();
vi.mock('../../../repo/paddleRegionLoadRepo', () => ({
paddleRegionLoadRepo: { load: (...args: unknown[]) => load(...args) },
}));
const { usePaddleRegionLoad } = await import('./usePaddleRegionLoad');
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
const AT = { lat: 49.0123456, lng: 12.0987654 };
const failures = [
{ index: 0, code: 'no_region' as const, at: AT },
{ index: 1, code: 'no_region' as const, at: AT },
{ index: 2, code: 'no_water_path' as const, at: null },
];
const loading = (fraction: number): PaddleRegionLoadState => ({
state: 'loading',
region: REF,
progress: { phase: 'download', fraction },
ahead: 0,
});
describe('usePaddleRegionLoad', () => {
beforeEach(() => load.mockReset());
afterEach(() => vi.useRealTimers());
it('FE-PADDLE-REGION-LOAD-001: asks once per place, and shows its state on every leg ending there', async () => {
load.mockResolvedValue([loading(0.4)]);
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
await waitFor(() => expect(result.current.byLeg.get(0)).toEqual(loading(0.4)));
expect(load).toHaveBeenCalledWith([{ lat: 49.01235, lng: 12.09877 }], false, expect.any(AbortSignal));
expect(result.current.byLeg.get(1)).toEqual(loading(0.4));
expect(result.current.byLeg.has(2)).toBe(false);
});
it('FE-PADDLE-REGION-LOAD-002: follows a load and plans again once, when the data is there', async () => {
vi.useFakeTimers();
load.mockResolvedValueOnce([loading(0.2)]).mockResolvedValue([{ state: 'ready', region: REF }]);
const onReady = vi.fn();
const { rerender } = renderHook(() => usePaddleRegionLoad(failures, onReady));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(onReady).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000);
});
expect(load).toHaveBeenCalledTimes(2);
expect(onReady).toHaveBeenCalledTimes(1);
rerender();
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
expect(onReady).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-REGION-LOAD-003: asks for nothing without a leg that lacks the data', async () => {
renderHook(() => usePaddleRegionLoad([{ index: 0, code: 'not_on_water', at: AT }], vi.fn()));
renderHook(() => usePaddleRegionLoad(null, vi.fn()));
await Promise.resolve();
expect(load).not.toHaveBeenCalled();
});
it('FE-PADDLE-REGION-LOAD-004: reads a failed region again on request', async () => {
load.mockResolvedValue([{ state: 'failed', region: REF }]);
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
await waitFor(() => expect(result.current.byLeg.get(0)?.state).toBe('failed'));
act(() => result.current.retry());
await waitFor(() => expect(load).toHaveBeenLastCalledWith(expect.any(Array), true, expect.any(AbortSignal)));
});
it('FE-PADDLE-REGION-LOAD-005: keeps asking after a failed look, more slowly', async () => {
vi.useFakeTimers();
load.mockRejectedValueOnce(new Error('offline')).mockResolvedValue([{ state: 'declined', region: REF }]);
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(load).toHaveBeenCalledTimes(2);
expect(result.current.byLeg.get(0)?.state).toBe('declined');
});
});
@@ -0,0 +1,108 @@
import { MAX_PADDLE_LOAD_POINTS, type PaddleRegionLoadState, type PaddleRoutePoint } from '@trek/shared';
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import { paddleRegionLoadRepo } from '../../../repo/paddleRegionLoadRepo';
import type { PaddleTourRoute } from '../planner/paddleRouting';
/** How soon the planner asks again: often while a region loads, now and then while it waits for an admin. */
const POLL_MS = { loading: 2_000, requested: 30_000, error: 10_000 } as const;
type Failure = PaddleTourRoute['failures'][number];
/** A place as the key it is asked and remembered by; five decimals are about a metre. */
function keyOf(point: PaddleRoutePoint): string {
return `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`;
}
function placeOf(key: string): PaddleRoutePoint {
const [lat, lng] = key.split(',').map(Number);
return { lat, lng };
}
/** The places the planner asks about: where its legs ran out of water data, each once. */
function placeKeysOf(failures: readonly Failure[] | null | undefined): string {
const keys = new Set<string>();
for (const failure of failures ?? []) {
if (failure.code === 'no_region' && failure.at) keys.add(keyOf(failure.at));
}
return [...keys].slice(0, MAX_PADDLE_LOAD_POINTS).join('|');
}
function nextPoll(states: readonly PaddleRegionLoadState[]): number | null {
if (states.some((state) => state.state === 'loading')) return POLL_MS.loading;
if (states.some((state) => state.state === 'requested')) return POLL_MS.requested;
return null;
}
/**
* PAD-11C: where a paddle leg failed for want of water data, the planner
* asks the server for it right away; the server loads the region around the
* place or asks an admin for it, as the instance is set up. While it loads
* the leg shows how far it is, and once it is there the route is planned
* again on its own. `onReady` plans again; it is called once per place.
*/
export function usePaddleRegionLoad(failures: readonly Failure[] | null | undefined, onReady: () => void) {
const placesKey = placeKeysOf(failures);
const places = useMemo(() => (placesKey ? placesKey.split('|').map(placeOf) : []), [placesKey]);
const [states, setStates] = useState<ReadonlyMap<string, PaddleRegionLoadState>>(new Map());
const [retryToken, setRetryToken] = useState(0);
const readied = useRef(new Set<string>());
const retrying = useRef(false);
/** The answers taken in; the route is planned again where a place's data has just arrived. */
const take = useEffectEvent((answers: readonly PaddleRegionLoadState[]) => {
setStates(new Map(places.flatMap((place, index) => (answers[index] ? [[keyOf(place), answers[index]]] : []))));
const fresh = places.filter((place, index) => {
const key = keyOf(place);
if (answers[index]?.state !== 'ready' || readied.current.has(key)) return false;
readied.current.add(key);
return true;
});
if (fresh.length > 0) onReady();
});
useEffect(() => {
if (places.length === 0) return undefined;
const abort = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const ask = async () => {
const retry = retrying.current;
retrying.current = false;
let answers: PaddleRegionLoadState[] | null;
try {
answers = await paddleRegionLoadRepo.load(places, retry, abort.signal);
} catch {
// The failure row keeps saying why the leg is unplanned; a later look may get through.
if (!abort.signal.aborted) timer = setTimeout(() => void ask(), POLL_MS.error);
return;
}
if (abort.signal.aborted || !answers) return;
take(answers);
const wait = nextPoll(answers);
if (wait !== null) timer = setTimeout(() => void ask(), wait);
};
void ask();
return () => {
abort.abort();
clearTimeout(timer);
};
}, [places, retryToken]);
/** Reads a region again whose last read failed. */
const retry = useCallback(() => {
retrying.current = true;
setRetryToken((token) => token + 1);
}, []);
/** The state of each failed leg's place, by the leg's index. */
const byLeg = useMemo(() => {
const result = new Map<number, PaddleRegionLoadState>();
for (const failure of failures ?? []) {
if (failure.code !== 'no_region' || !failure.at) continue;
const state = states.get(keyOf(failure.at));
if (state) result.set(failure.index, state);
}
return result;
}, [failures, states]);
return { byLeg, retry };
}
@@ -0,0 +1,90 @@
// FE-PORTAGE-ROUTING-001 to 005: portages set in the planner follow the ways on land (PAD-03).
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { portageLegBetween } from '../planner/tourRouteActions';
const routePortageLine = vi.fn();
vi.mock('./portageRoute', () => ({ routePortageLine: (...args: unknown[]) => routePortageLine(...args) }));
const { usePortageRouting } = await import('./usePortageRouting');
const A: TourPlannerWaypoint = { id: 'a', lat: 53.2232, lng: 12.9668, role: 'start' };
const B: TourPlannerWaypoint = { id: 'b', lat: 53.2229, lng: 12.9701, role: 'end' };
const WAY: [number, number][] = [
[A.lat, A.lng],
[53.2226, 12.9686],
[B.lat, B.lng],
];
function plannerWith(waypoints: TourPlannerWaypoint[], hasUnsavedChanges: boolean) {
const planner = {
waypoints,
isPaddle: true,
hasUnsavedChanges,
amendWaypoints: vi.fn((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
planner.waypoints = change(planner.waypoints);
}),
editPaddlePoints: vi.fn((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
planner.waypoints = change(planner.waypoints);
}),
};
return planner;
}
const carried = (): TourPlannerWaypoint[] => [{ ...A, fixedLeg: portageLegBetween(A, B) }, B];
describe('usePortageRouting', () => {
beforeEach(() => routePortageLine.mockReset());
it('FE-PORTAGE-ROUTING-001: lays a portage the paddler just set on the way the router found', async () => {
routePortageLine.mockResolvedValue(WAY);
const planner = plannerWith(carried(), true);
renderHook(() => usePortageRouting(planner, true));
await waitFor(() => expect(planner.amendWaypoints).toHaveBeenCalled());
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual(WAY);
});
it('FE-PORTAGE-ROUTING-002: leaves a tour as saved alone, and asks nobody while it cannot be edited', async () => {
const saved = plannerWith(carried(), false);
renderHook(() => usePortageRouting(saved, true));
const locked = plannerWith(carried(), true);
renderHook(() => usePortageRouting(locked, false));
await Promise.resolve();
expect(routePortageLine).not.toHaveBeenCalled();
});
it('FE-PORTAGE-ROUTING-003: keeps a carry straight when the router found no way, and says so', async () => {
routePortageLine.mockResolvedValue(null);
const planner = plannerWith(carried(), true);
const { result } = renderHook(() => usePortageRouting(planner, true));
await waitFor(() => expect(routePortageLine).toHaveBeenCalledTimes(1));
await waitFor(() => expect(result.current.noWay(['a'])).toBe(true));
expect(planner.waypoints[0].fixedLeg?.geometry).toHaveLength(2);
});
it('FE-PORTAGE-ROUTING-004: lays a saved straight portage on the way when asked to', async () => {
routePortageLine.mockResolvedValue(WAY);
const planner = plannerWith(carried(), false);
const { result } = renderHook(() => usePortageRouting(planner, true));
let found = false;
await act(async () => {
found = await result.current.follow(['a']);
});
expect(found).toBe(true);
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual(WAY);
});
it('FE-PORTAGE-ROUTING-005: draws a portage straight on request and keeps it so', async () => {
const planner = plannerWith([{ ...A, fixedLeg: { ...portageLegBetween(A, B), geometry: WAY } }, B], true);
const { result, rerender } = renderHook(() => usePortageRouting(planner, true));
act(() => result.current.straighten(['a']));
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual([
[A.lat, A.lng],
[B.lat, B.lng],
]);
rerender();
await Promise.resolve();
expect(routePortageLine).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,194 @@
import type { TourFixedLeg } from '@trek/shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { TourPlannerController } from '../planner/useTourPlanner';
import type { PortageWay } from './PaddleWaypointParts';
import { routePortageLine } from './portageRoute';
type Planner = Pick<
TourPlannerController,
'waypoints' | 'isPaddle' | 'hasUnsavedChanges' | 'amendWaypoints' | 'editPaddlePoints'
>;
type Point = TourPlannerController['waypoints'][number];
type Line = [number, number][];
/** A portage leg by its two ends, to six decimals (about ten centimetres): the same carry asks once. */
function keyOf(from: Point, to: Point): string {
return `${from.lat.toFixed(6)},${from.lng.toFixed(6)}>${to.lat.toFixed(6)},${to.lng.toFixed(6)}`;
}
function isStraightPortage(leg: TourFixedLeg | null | undefined): boolean {
return leg?.kind === 'portage' && leg.geometry.length === 2;
}
/** The portages still drawn straight, with the point after each. */
function straightPortages(points: readonly Point[]) {
return points.flatMap((point, index) => {
const next = points[index + 1];
// A portage the water router found is its own: it already follows the mapped way.
return next && !point.auto && isStraightPortage(point.fixedLeg)
? [{ id: point.id, from: point, to: next, key: keyOf(point, next) }]
: [];
});
}
/**
* PAD-03: a portage follows the ways on land instead of cutting straight
* across them. A portage the paddler sets, or whose ends they move, is asked
* of the router the hikes use and takes its line, unless that line is a
* detour; then it stays straight. A tour opened as saved keeps its straight
* portages until the paddler asks for the way (`follow`), and one they set
* straight on purpose (`straighten`) stays so.
*/
export function usePortageRouting(planner: Planner, editable: boolean) {
const { waypoints, isPaddle, hasUnsavedChanges, amendWaypoints, editPaddlePoints } = planner;
/** Carries kept straight: as the tour was saved, or as the paddler set them. */
const kept = useRef(new Set<string>());
/** What the router answered per carry; null when it found no way worth taking. */
const lines = useRef(new Map<string, Line | null>());
const layIn = useCallback(() => {
amendWaypoints((current) => {
let changed = false;
const next = current.map((point, index) => {
const after = current[index + 1];
const leg = point.fixedLeg;
if (!after || point.auto || !leg || leg.kind !== 'portage' || leg.geometry.length !== 2) return point;
const key = keyOf(point, after);
const line = kept.current.has(key) ? null : lines.current.get(key);
if (!line) return point;
changed = true;
return { ...point, fixedLeg: { ...leg, geometry: line } };
});
return changed ? next : current;
});
}, [amendWaypoints]);
useEffect(() => {
if (!isPaddle || !editable) return undefined;
const legs = straightPortages(waypoints);
if (!hasUnsavedChanges) {
for (const leg of legs) kept.current.add(leg.key);
return undefined;
}
const wanted = legs.filter((leg) => !kept.current.has(leg.key));
if (wanted.some((leg) => lines.current.get(leg.key))) layIn();
const missing = wanted.filter((leg) => !lines.current.has(leg.key));
if (missing.length === 0) return undefined;
const abort = new AbortController();
// One after the other: the public router allows about one request a second.
void missing
.reduce(async (previous, leg) => {
await previous;
if (abort.signal.aborted) return;
const line = await routePortageLine(leg.from, leg.to, abort.signal);
if (!abort.signal.aborted) lines.current.set(leg.key, line);
}, Promise.resolve())
.then(() => {
if (!abort.signal.aborted) layIn();
});
return () => abort.abort();
}, [editable, hasUnsavedChanges, isPaddle, layIn, waypoints]);
/** The carries starting at these points, each with the point after it. */
const legsOf = useCallback(
(ids: readonly string[]) =>
waypoints.flatMap((point, index) => {
const next = waypoints[index + 1];
return ids.includes(point.id) && next ? [{ point, next, key: keyOf(point, next) }] : [];
}),
[waypoints]
);
/**
* A portage laid on the ways, after it was straight (as saved, or set so),
* leg by leg: true once one follows them, false when there is no way worth
* taking and it stays straight.
*/
const follow = useCallback(
async (ids: readonly string[]) => {
const legs = legsOf(ids);
const found = await legs.reduce(async (previous, { point, next, key }) => {
const any = await previous;
kept.current.delete(key);
const line = await routePortageLine(point, next);
lines.current.set(key, line);
return any || !!line;
}, Promise.resolve(false));
if (found) layIn();
return found;
},
[layIn, legsOf]
);
/** A portage drawn straight, and kept so. */
const straighten = useCallback(
(ids: readonly string[]) => {
for (const { key } of legsOf(ids)) kept.current.add(key);
editPaddlePoints((current) =>
current.map((point, index) => {
const next = current[index + 1];
const leg = point.fixedLeg;
if (!ids.includes(point.id) || !next || leg?.kind !== 'portage') return point;
return {
...point,
fixedLeg: {
...leg,
geometry: [
[point.lat, point.lng],
[next.lat, next.lng],
],
},
};
})
);
},
[editPaddlePoints, legsOf]
);
/** Whether the router found no way worth taking for every leg of a portage. */
const noWay = useCallback(
(ids: readonly string[]) => {
const legs = legsOf(ids);
return legs.length > 0 && legs.every(({ key }) => lines.current.get(key) === null);
},
[legsOf]
);
return { follow, straighten, noWay };
}
type PortageRouting = ReturnType<typeof usePortageRouting>;
/** The waypoints whose legs make up the portage starting at `index`: one leg, or several in a row. */
function portageRunIds(points: readonly Point[], index: number): string[] {
const ids: string[] = [];
for (let at = index; at < points.length - 1 && points[at]?.fixedLeg?.kind === 'portage'; at += 1) {
ids.push(points[at].id);
}
return ids;
}
/**
* PAD-03: the way of the portage starting at a waypoint, for its card:
* whether it follows the ways on land, why it stayed straight, and the switch
* between the two. Undefined where no portage starts.
*/
export function usePortageWay(
points: readonly Point[],
routing: PortageRouting,
index: number
): PortageWay | undefined {
const [searching, setSearching] = useState(false);
const ids = portageRunIds(points, index);
if (ids.length === 0) return undefined;
return {
routed: ids.every((id) => (points.find((point) => point.id === id)?.fixedLeg?.geometry.length ?? 0) > 2),
noWay: routing.noWay(ids),
searching,
onFollow: () => {
setSearching(true);
void routing.follow(ids).finally(() => setSearching(false));
},
onStraighten: () => routing.straighten(ids),
};
}
@@ -926,6 +926,24 @@ describe('the paddle checklist in the rails (PAD-06)', () => {
expect(safetyRow()).toBeInTheDocument() expect(safetyRow()).toBeInTheDocument()
}) })
it('PCR-006: legs the water router could not plan are counted in the head, without the retry the rows already offer', () => {
const failed = paddleEditor({
status: 'routing-failed',
error: 'paddle-route',
paddleRoute: { coordinates: [], parts: [], distanceMeters: 0, legs: [], legLengths: [], failures: [
{ index: 0, code: 'not_on_water', at: null },
{ index: 1, code: 'busy', at: null },
] },
})
const { rerender } = render(<TourPlannerRail planner={failed} />)
expect(within(screen.getByTestId('tour-planner-status')).getByLabelText('2 legs not planned')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status: 'routing-failed', error: 'routing' })} />)
expect(within(screen.getByTestId('tour-planner-status')).getByLabelText('Route calculation failed. The last good route is still shown.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
it('PCR-005: a recorded kayak track scrolls its checklist before the wind and keeps the safety row in a footer; a recorded hike neither', () => { it('PCR-005: a recorded kayak track scrolls its checklist before the wind and keeps the safety row in a footer; a recorded hike neither', () => {
const { rerender } = render(<TourPlannerRail planner={recorded(kayak)} paddleChecklist={checklist} />) const { rerender } = render(<TourPlannerRail planner={recorded(kayak)} paddleChecklist={checklist} />)
@@ -19,6 +19,7 @@ import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts' import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts'
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts' import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
import { TourDetailsSection, TourNameField } from './TourEditorFields' import { TourDetailsSection, TourNameField } from './TourEditorFields'
import { crossesLake } from '../paddle/lakeStyleAdvice'
import { PaddleCheckCard } from '../paddle/PaddleCheckCard' import { PaddleCheckCard } from '../paddle/PaddleCheckCard'
import { PaddleSafetyRow } from '../paddle/PaddleSafety' import { PaddleSafetyRow } from '../paddle/PaddleSafety'
import { PaddleTimeCard } from '../paddle/PaddleTimeCard' import { PaddleTimeCard } from '../paddle/PaddleTimeCard'
@@ -121,6 +122,18 @@ function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' |
) )
} }
/** Whether the head offers to try again; a paddle leg that failed has its own actions on its row (PAD-11). */
function retryable(planner: TourPlannerController): boolean {
if (planner.error === 'paddle-route') return false
return planner.status === 'routing-failed' || planner.status === 'elevation-failed'
}
/** The status in words; a paddle tour with legs the router could not plan says how many (PAD-11). */
function statusText(planner: TourPlannerController, key: string, t: Translate): string {
if (planner.error === 'paddle-route') return t('tours.paddle.status.unrouted', { count: planner.paddleRoute?.failures.length ?? 1 })
return t(key)
}
/** /**
* What the editor is doing right now, as icon badges beside the head's title: * What the editor is doing right now, as icon badges beside the head's title:
* a restored draft, routing or saving, and a failure with its retry. Nothing * a restored draft, routing or saving, and a failure with its retry. Nothing
@@ -132,10 +145,10 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
const restored = planner.draftRestored && planner.hasUnsavedChanges const restored = planner.draftRestored && planner.hasUnsavedChanges
const onFerrata = useTourFerrataCrossingStore(state => state.crossings.length > 0) const onFerrata = useTourFerrataCrossingStore(state => state.crossings.length > 0)
const failed = planner.error === 'save' const failed = planner.error === 'save'
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed' const canRetry = retryable(planner)
if (!restored && !statusKey && !failed && !onFerrata) return null if (!restored && !statusKey && !failed && !onFerrata) return null
const warning = planner.error != null || canRetry const warning = planner.error != null || canRetry
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? statusText(planner, statusKey, t) : null
const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation' const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation'
return ( return (
<span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1"> <span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1">
@@ -326,7 +339,11 @@ function PaddleEditorCards({ planner, locked, checklist }: {
<> <>
{checklist && <PaddleCheckCard checklist={checklist} />} {checklist && <PaddleCheckCard checklist={checklist} />}
<PaddleTimeCard planner={planner} locked={locked} /> <PaddleTimeCard planner={planner} locked={locked} />
<PaddleWindSection placeId={planner.editingPlaceId} start={planner.waypoints[0] ?? null} /> <PaddleWindSection
placeId={planner.editingPlaceId}
start={planner.waypoints[0] ?? null}
lakes={!locked && crossesLake(planner.paddleRoute) ? { value: planner.paddle.lakeStyle, onChoose: planner.paddle.setLakeStyle } : undefined}
/>
</> </>
) )
} }
@@ -75,3 +75,33 @@ describe('TourTypeFields (PAD-01)', () => {
expect(screen.getByText(/enter a speed|speed of your own/i)).toBeInTheDocument(); expect(screen.getByText(/enter a speed|speed of your own/i)).toBeInTheDocument();
}); });
}); });
describe('TourTypeFields lake style (PAD-11)', () => {
it('asks how to cross lakes on lake water or a route over a lake, straight over by default, and hands a change on', () => {
const river = planner({}, { waterContext: 'river' });
const { rerender } = render(<TourTypeFields planner={river} locked={false} />);
expect(screen.queryByTestId('tour-planner-lake-style')).not.toBeInTheDocument();
const lake = planner({}, { waterContext: 'lake' });
rerender(<TourTypeFields planner={lake} locked={false} />);
expect(screen.getByRole('button', { name: 'Straight over' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('The shortest line across open water.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Near the shore' }));
expect(lake.paddle.setLakeStyle).toHaveBeenCalledWith('shore');
const lakeLeg = {
status: 'ok',
geometry: [
[53.4, 12.7],
[53.5, 12.7],
],
segments: [{ kind: 'lake', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null }],
distance_m: 11000,
snapped_m: { from: 0, to: 0 },
};
const crossing = planner({ paddleRoute: { legs: [lakeLeg] } }, { waterContext: 'river', lakeStyle: 'shore' });
rerender(<TourTypeFields planner={crossing} locked={false} />);
expect(screen.getByRole('button', { name: 'Near the shore' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('Stays near the bank, for windy days: longer, but land is never far.')).toBeInTheDocument();
});
});
@@ -10,6 +10,8 @@ import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts'; import { SoftPill } from '../../Planner/planParts';
import { INPUT, LABEL, Segmented } from '../../shared/dialogParts'; import { INPUT, LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell'; import { fs } from '../../shared/DialogShell';
import { crossesLake } from '../paddle/lakeStyleAdvice';
import { PaddleLakeStyleField } from '../paddle/PaddleLakeStyleField';
import { TourNotice } from '../tourParts'; import { TourNotice } from '../tourParts';
import type { TourPlannerController } from './useTourPlanner'; import type { TourPlannerController } from './useTourPlanner';
@@ -126,6 +128,9 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
options={WATERS.map((water) => ({ value: water, label: t(`tours.paddle.water.${water}`) }))} options={WATERS.map((water) => ({ value: water, label: t(`tours.paddle.water.${water}`) }))}
/> />
</div> </div>
{(paddle.waterContext === 'lake' || crossesLake(planner.paddleRoute)) && (
<PaddleLakeStyleField value={paddle.lakeStyle} onChange={paddle.setLakeStyle} />
)}
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1.5"> <div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1.5">
<label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor="tour-planner-paddle-speed"> <label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor="tour-planner-paddle-speed">
@@ -0,0 +1,174 @@
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-006: shaping points and the water router's answers in the list (P2A-03, P2A-05, PAD-11).
import userEvent from '@testing-library/user-event';
import type { PaddleRouteLeg } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
import { useSettingsStore } from '../../../store/settingsStore';
import type { PaddleTourRoute } from './paddleRouting';
import TourWaypointList from './TourWaypointList';
import type { TourPlannerController } from './useTourPlanner';
/** The item at `i`, or a failure that names the index instead of a later undefined. */
function at<T>(list: readonly T[], i: number): T {
const item = list[i];
if (item === undefined) throw new Error(`nothing at ${i}`);
return item;
}
vi.mock('./useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
const START = { id: 'a', lat: 49.1, lng: 12.1, role: 'start' as const };
const SHAPE = { id: 's', lat: 49.08, lng: 12.11, role: 'via' as const, shaping: true };
const STOP = { id: 'b', lat: 49.05, lng: 12.12, role: 'via' as const };
const END = { id: 'c', lat: 49.0, lng: 12.1, role: 'end' as const };
const segment = (kind: 'water' | 'rapids' | 'pass' | 'gap', from: number, to: number, grade: number | null = null) => ({
kind,
from,
to,
name: null,
source: null,
osm_ref: null,
grade,
});
const WHITEWATER: PaddleRouteLeg = {
status: 'ok',
geometry: [
[49.05, 12.12],
[49.04, 12.12],
[49.03, 12.115],
[49.0, 12.1],
],
segments: [segment('water', 0, 1), segment('rapids', 1, 2, 2), segment('gap', 2, 3)],
distance_m: 5800,
snapped_m: { from: 0, to: 0 },
};
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
return {
...hikePlannerDefaults(),
waypoints: [START, SHAPE, STOP, END],
selectedWaypointId: null,
setSelectedWaypointId: vi.fn(),
reorderWaypoint: vi.fn(),
moveWaypoint: vi.fn(),
removeWaypoint: vi.fn(),
...overrides,
} as unknown as TourPlannerController;
}
function paddleRoute(legs: PaddleTourRoute['legs'], failures: PaddleTourRoute['failures'] = []): PaddleTourRoute {
return { coordinates: [], parts: [], distanceMeters: 0, legs, legLengths: legs.map(() => 0), failures };
}
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
});
describe('TourWaypointList', () => {
it('FE-TOUR-WAYPOINT-LIST-001: numbers the stops only; a shaping point is a dot and does not count', () => {
render(<TourWaypointList planner={planner()} canEdit isSaving={false} />);
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
expect(rows).toHaveLength(4);
expect(within(at(rows, 1)).getByLabelText('Shaping point')).toHaveAttribute('data-waypoint-shaping', 'true');
expect(
[...document.querySelectorAll('[data-waypoint-number]')].map((el) => el.getAttribute('data-waypoint-number'))
).toEqual(['1', '2', '3']);
expect(screen.getByText('Click the route on the map to add a shaping point there.')).toBeInTheDocument();
});
it('FE-TOUR-WAYPOINT-LIST-002: the menu plans a routed leg again, draws it by hand, and turns a stop into a shaping point', async () => {
const controller = planner({ selectedWaypointId: 'b' });
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
const row = at(screen.getAllByTestId('tour-planner-waypoint-row'), 2);
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
await userEvent.click(screen.getByRole('button', { name: 'Plan the next leg again' }));
expect(controller.recomputeLeg).toHaveBeenCalledWith('b');
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
await userEvent.click(screen.getByRole('button', { name: 'Make it a shaping point' }));
expect(controller.setWaypointShaping).toHaveBeenCalledWith('b', true);
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
await userEvent.click(screen.getByRole('button', { name: 'Draw the next leg by hand' }));
expect(controller.toggleWaypointFreeLeg).toHaveBeenCalledWith('b');
});
it('FE-TOUR-WAYPOINT-LIST-003: a shaping point becomes a stop again; the first point offers no shaping', async () => {
const controller = planner({ selectedWaypointId: 's' });
const { rerender } = render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
await userEvent.click(
within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 1)).getByRole('button', { name: 'More actions' })
);
await userEvent.click(screen.getByRole('button', { name: 'Make it a stop' }));
expect(controller.setWaypointShaping).toHaveBeenCalledWith('s', false);
rerender(<TourWaypointList planner={planner({ selectedWaypointId: 'a' })} canEdit isSaving={false} />);
await userEvent.click(
within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 0)).getByRole('button', { name: 'More actions' })
);
expect(screen.queryByRole('button', { name: 'Make it a shaping point' })).not.toBeInTheDocument();
});
it('FE-TOUR-WAYPOINT-LIST-004: a paddle leg shows its whitewater and its data gap with their lengths', () => {
const route = paddleRoute([null, null, WHITEWATER]);
render(
<TourWaypointList
planner={planner({ ...{ isPaddle: true, tourType: 'kayak' }, paddleRoute: route, routeCurrent: true })}
canEdit
isSaving={false}
/>
);
const notes = screen.getByTestId('tour-planner-leg-notes');
expect(within(notes).getByText('Whitewater 2')).toBeInTheDocument();
expect(within(notes).getByText('Gap in the map data')).toBeInTheDocument();
});
it('FE-TOUR-WAYPOINT-LIST-005: a leg the router could not plan says why and offers the way out', async () => {
const route = paddleRoute(
[{ status: 'failed', code: 'busy', at: null }, null, null],
[{ index: 0, code: 'busy', at: null }]
);
const controller = planner({ isPaddle: true, tourType: 'kayak', paddleRoute: route, routeCurrent: true });
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
const failure = screen.getByTestId('tour-planner-leg-failure');
expect(within(failure).getByRole('alert')).toHaveTextContent('The route planner is busy. Try again in a moment.');
await userEvent.click(within(failure).getByRole('button', { name: 'Try again' }));
expect(controller.recomputeLeg).toHaveBeenCalledWith('a');
await userEvent.click(within(failure).getByRole('button', { name: 'Draw straight' }));
expect(controller.toggleWaypointFreeLeg).toHaveBeenCalledWith('a');
});
it('FE-TOUR-WAYPOINT-LIST-006: a point off the water offers no retry; legs drawn by hand can go back to the water in one step', async () => {
const drawn = {
kind: 'free' as const,
geometry: [
[49.1, 12.1],
[49.08, 12.11],
] as [number, number][],
};
const route = paddleRoute(
[null, { status: 'failed', code: 'not_on_water', at: null }, null],
[{ index: 1, code: 'not_on_water', at: null }]
);
const auto = { from: { id: 'a', lat: 49.1, lng: 12.1 }, to: { id: 'c', lat: 49.0, lng: 12.1 } };
const controller = planner({
isPaddle: true,
tourType: 'kayak',
paddleRoute: route,
routeCurrent: true,
waypoints: [{ ...START, fixedLeg: drawn }, SHAPE, { ...STOP, auto }, END],
});
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
expect(
within(screen.getByTestId('tour-planner-leg-failure')).queryByRole('button', { name: 'Try again' })
).toBeNull();
expect(screen.getByTestId('tour-planner-auto-point')).toHaveAttribute('aria-label', 'Found');
await userEvent.click(screen.getByTestId('tour-planner-route-on-water'));
expect(controller.routeDrawnLegsOnWater).toHaveBeenCalled();
});
});
@@ -1,16 +1,24 @@
import { paddleLayoutOfPoints, type PaddleLayout, type PaddleRole, type TourFixedLeg } from '@trek/shared'; import {
paddleLayoutOfPoints,
type PaddleLayout,
type PaddleRegionLoadState,
type PaddleRole,
type TourFixedLeg,
} from '@trek/shared';
import { import {
ArrowDown, ArrowDown,
ArrowDownUp, ArrowDownUp,
ArrowUp, ArrowUp,
GripVertical, GripVertical,
Info,
MapPin, MapPin,
MapPinPlus, MapPinPlus,
MoreHorizontal, MoreHorizontal,
Navigation,
RefreshCw,
Repeat, Repeat,
Spline, Spline,
Trash2, Trash2,
Waves,
} from 'lucide-react'; } from 'lucide-react';
import { Fragment, useState, type MouseEvent, type ReactNode } from 'react'; import { Fragment, useState, type MouseEvent, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n'; import { useTranslation } from '../../../i18n';
@@ -28,10 +36,16 @@ import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
import { fs } from '../../shared/DialogShell'; import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip'; import { Tooltip } from '../../shared/Tooltip';
import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph'; import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph';
import { LockRow, PaddleRolePill, PortageLegRow } from '../paddle/PaddleWaypointParts'; import { AutoPointPill, PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts';
import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad';
import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow';
import { LockRow, PaddleRolePill, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts';
import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions'; import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
import { RailCard, TourNotice } from '../tourParts'; import { usePortageRouting, usePortageWay } from '../paddle/usePortageRouting';
import { RailCard } from '../tourParts';
import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf'; import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf';
import type { PaddleLegFailure } from './paddleRouting';
import { lineLengthM } from './tourFixedLegs'; import { lineLengthM } from './tourFixedLegs';
import type { TourPlannerController } from './useTourPlanner'; import type { TourPlannerController } from './useTourPlanner';
import { useWaypointPlaceNames } from './useWaypointPlaceNames'; import { useWaypointPlaceNames } from './useWaypointPlaceNames';
@@ -207,18 +221,35 @@ function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
* by the tooltip. * by the tooltip.
*/ */
function WaypointNumber({ function WaypointNumber({
index, stop,
selected, selected,
ferrata, ferrata,
loopEnd = false, loopEnd = false,
shaping = false,
}: { }: {
index: number; /** The stop's number; shaping points do not count, as on the map. */
stop: number;
selected: boolean; selected: boolean;
ferrata: TourWaypointFerrata | null; ferrata: TourWaypointFerrata | null;
/** The way back to the start of a loop: shown as the return, not as one more stop. */ /** The way back to the start of a loop: shown as the return, not as one more stop. */
loopEnd?: boolean; loopEnd?: boolean;
/** P2A-03: a shaping point, a small dot as on the map. */
shaping?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (shaping) {
return (
<span
aria-label={t('tours.planner.shapingPoint')}
data-waypoint-shaping="true"
className="grid h-7 w-7 flex-none place-items-center"
>
<span
className={`h-3 w-3 rounded-full ring-2 ring-surface-card ${selected ? 'bg-accent' : 'bg-content-faint'}`}
/>
</span>
);
}
if (loopEnd) { if (loopEnd) {
return ( return (
<span <span
@@ -230,16 +261,16 @@ function WaypointNumber({
</span> </span>
); );
} }
const label = t('tours.planner.waypointLabel', { n: index + 1 }); const label = t('tours.planner.waypointLabel', { n: stop });
const number = ( const number = (
<span <span
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label} aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
data-waypoint-number={index + 1} data-waypoint-number={stop}
data-ferrata-end={ferrata ? 'true' : undefined} data-ferrata-end={ferrata ? 'true' : undefined}
className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`} className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`}
style={fs(11.5)} style={fs(11.5)}
> >
{index + 1} {stop}
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />} {ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
</span> </span>
); );
@@ -278,9 +309,18 @@ function WaypointRowLabel({
> >
{name ?? coordinates} {name ?? coordinates}
</span> </span>
{paddleRole !== undefined {point.shaping ? (
? paddleRole && !loopEnd && <PaddleRolePill role={paddleRole} /> <Tooltip label={t('tours.planner.shapingHint')}>
: point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>} <span className="inline-flex">
<SoftPill>{t('tours.planner.shapingPoint')}</SoftPill>
</span>
</Tooltip>
) : paddleRole !== undefined ? (
paddleRole && !loopEnd && <PaddleRolePill role={paddleRole} />
) : (
point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>
)}
{point.auto && <AutoPointPill />}
</span> </span>
{name && !loopEnd && ( {name && !loopEnd && (
<span <span
@@ -302,6 +342,82 @@ function WaypointRowLabel({
* and remove beside it. Two controls keep the place name readable in a narrow * and remove beside it. Two controls keep the place name readable in a narrow
* rail however many tools a tour type adds. * rail however many tools a tour type adds.
*/ */
type MenuItems = Parameters<ReturnType<typeof useContextMenu>['open']>[1];
type Translate = ReturnType<typeof useTranslation>['t'];
type WaypointPoint = TourPlannerController['waypoints'][number];
/**
* The tools for the leg after a point: drawn by hand or routed (on a paddle
* tour: on the water), planned again (P2A-05), and the point a stop or a
* shaping point (P2A-03; never the first or the last).
*/
function routeMenuItems(
planner: TourPlannerController,
point: WaypointPoint,
index: number,
lastIndex: number,
t: Translate
): MenuItems {
const hasLeg = index < lastIndex;
const free = point.fixedLeg?.kind === 'free';
const routed = hasLeg && !point.fixedLeg;
const items: MenuItems = [];
if (free || routed) {
const routeAgain = planner.isPaddle ? t('tours.paddle.routeOnWater') : t('tours.planner.routeLeg');
items.push(
{ divider: true },
{
label: free ? routeAgain : t('tours.planner.freeLeg'),
icon: free && planner.isPaddle ? Waves : Spline,
onClick: () => planner.toggleWaypointFreeLeg(point.id),
}
);
}
if (routed)
items.push({
label: t('tours.planner.recomputeLeg'),
icon: RefreshCw,
onClick: () => planner.recomputeLeg(point.id),
});
if (index > 0 && index < lastIndex) {
items.push({
label: t(point.shaping ? 'tours.planner.makeStop' : 'tours.planner.makeShaping'),
icon: Navigation,
onClick: () => planner.setWaypointShaping(point.id, !point.shaping),
});
}
return items;
}
/** A paddle tour's tools at a point: carry the boat from here, and a lock here. */
function paddleMenuItems(
paddle: ReturnType<typeof usePaddleWaypointActions>,
point: WaypointPoint,
hasLeg: boolean,
t: Translate
): MenuItems {
const portage = point.fixedLeg?.kind === 'portage';
const items: MenuItems = [{ divider: true }];
// At the last point too: the portage starts here and reaches the point set next.
items.push({
label: t(
!hasLeg && point.carryNext
? 'tours.paddle.portagePendingOff'
: portage
? 'tours.paddle.portageOff'
: 'tours.paddle.portageOn'
),
icon: PortageIcon,
onClick: () => paddle.togglePortage(point.id),
});
items.push({
label: t(point.lock ? 'tours.paddle.lockRemove' : 'tours.paddle.lockAdd'),
icon: LockIcon,
onClick: () => paddle.setLock(point.id, point.lock ? null : { wait_minutes: null, transit_minutes: null }),
});
return items;
}
function WaypointRowActions({ function WaypointRowActions({
planner, planner,
point, point,
@@ -311,7 +427,7 @@ function WaypointRowActions({
isSaving, isSaving,
}: { }: {
planner: TourPlannerController; planner: TourPlannerController;
point: TourPlannerController['waypoints'][number]; point: WaypointPoint;
index: number; index: number;
lastIndex: number; lastIndex: number;
selected: boolean; selected: boolean;
@@ -320,10 +436,7 @@ function WaypointRowActions({
const { t } = useTranslation(); const { t } = useTranslation();
const menu = useContextMenu(); const menu = useContextMenu();
const paddle = usePaddleWaypointActions(planner); const paddle = usePaddleWaypointActions(planner);
const free = point.fixedLeg?.kind === 'free'; const items: MenuItems = [
const portage = point.fixedLeg?.kind === 'portage';
const hasLeg = index < lastIndex;
const items = [
{ {
label: t('tours.planner.moveUp'), label: t('tours.planner.moveUp'),
icon: ArrowUp, icon: ArrowUp,
@@ -336,35 +449,8 @@ function WaypointRowActions({
disabled: index === lastIndex, disabled: index === lastIndex,
onClick: () => planner.moveWaypoint(point.id, 1), onClick: () => planner.moveWaypoint(point.id, 1),
}, },
...(!planner.isPaddle && hasLeg && point.fixedLeg?.kind !== 'ferrata' ...routeMenuItems(planner, point, index, lastIndex, t),
? [ ...(planner.isPaddle ? paddleMenuItems(paddle, point, index < lastIndex, t) : []),
{ divider: true },
{
label: t(free ? 'tours.planner.routeLeg' : 'tours.planner.freeLeg'),
icon: Spline,
onClick: () => planner.toggleWaypointFreeLeg(point.id),
},
]
: []),
...(planner.isPaddle
? [
{ divider: true },
...(hasLeg
? [
{
label: t(portage ? 'tours.paddle.portageOff' : 'tours.paddle.portageOn'),
icon: PortageIcon,
onClick: () => paddle.togglePortage(point.id),
},
]
: []),
{
label: t(point.lock ? 'tours.paddle.lockRemove' : 'tours.paddle.lockAdd'),
icon: LockIcon,
onClick: () => paddle.setLock(point.id, point.lock ? null : { wait_minutes: null, transit_minutes: null }),
},
]
: []),
]; ];
return ( return (
<span <span
@@ -402,24 +488,29 @@ function PaddleRowExtras({
index, index,
layout, layout,
editable, editable,
routing,
}: { }: {
planner: TourPlannerController; planner: TourPlannerController;
point: TourPlannerController['waypoints'][number]; point: TourPlannerController['waypoints'][number];
index: number; index: number;
layout: PaddleLayout; layout: PaddleLayout;
editable: boolean; editable: boolean;
routing: ReturnType<typeof usePortageRouting>;
}) { }) {
const paddle = usePaddleWaypointActions(planner); const paddle = usePaddleWaypointActions(planner);
const way = usePortageWay(planner.waypoints, routing, index);
const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index); const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index);
const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined; const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined;
return ( return (
<> <>
{point.carryNext && <PendingPortageRow disabled={!editable} onRemove={() => paddle.togglePortage(point.id)} />}
{portage && ( {portage && (
<PortageLegRow <PortageLegRow
lengthM={portage.toM - portage.fromM} lengthM={portage.toM - portage.fromM}
extraMinutes={portage.extraMinutes} extraMinutes={portage.extraMinutes}
disabled={!editable} disabled={!editable}
onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)} onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)}
way={point.auto ? undefined : way}
/> />
)} )}
{point.lock && ( {point.lock && (
@@ -463,6 +554,154 @@ function AddPointRow({ disabled }: { disabled: boolean }) {
* reorderable by drag or by the arrow controls on each row. The coordinates * reorderable by drag or by the arrow controls on each row. The coordinates
* stand in for the name while it loads, offline, or when the lookup has none. * stand in for the name while it loads, offline, or when the lookup has none.
*/ */
/** P2A-03: how a shaping point is set, said once under the list while there is a route to click. */
function ShapeOnLineHint({ planner, canEdit }: { planner: TourPlannerController; canEdit: boolean }) {
const { t } = useTranslation();
if (!canEdit || planner.waypoints.length < 2) return null;
return (
<p className="m-0 mt-1.5 px-1.5 text-content-faint" style={fs(11, 'body')}>
{t('tours.planner.shapeOnLine')}
</p>
);
}
/** Stops are numbered on their own, as on the map: a shaping point (P2A-03) takes no number. */
function stopNumbersOf(points: ReadonlyArray<WaypointPoint>): Map<string, number> {
const numbers = new Map<string, number>();
for (const point of points) if (!point.shaping) numbers.set(point.id, numbers.size + 1);
return numbers;
}
/** The route tools in the card head: reverse, plan the drawn legs on the water (PAD-11), back to the start. */
function WaypointListTools({ planner, isSaving }: { planner: TourPlannerController; isSaving: boolean }) {
const { t } = useTranslation();
const hasDrawnLegs = planner.isPaddle && planner.waypoints.some((point) => point.fixedLeg?.kind === 'free');
return (
<span className="flex flex-none items-center gap-0.5">
<RouteToolButton
testId="tour-planner-reverse"
label={t('tours.planner.reverse')}
disabled={isSaving || planner.waypoints.length < 2}
onAct={planner.reverse}
>
<ArrowDownUp size={13} strokeWidth={2.2} />
</RouteToolButton>
{hasDrawnLegs && (
<RouteToolButton
testId="tour-planner-route-on-water"
label={t('tours.paddle.routeAllOnWater')}
disabled={isSaving}
onAct={planner.routeDrawnLegsOnWater}
>
<Waves size={13} strokeWidth={2.2} />
</RouteToolButton>
)}
<RouteToolButton
testId="tour-planner-return-to-start"
label={t(planner.isLoop ? 'tours.planner.alreadyLoop' : 'tours.planner.returnToStart')}
disabled={isSaving || planner.waypoints.length < 2 || planner.isLoop}
onAct={planner.returnToStart}
>
<Repeat size={13} strokeWidth={2.2} />
</RouteToolButton>
</span>
);
}
/**
* A leg the router could not plan. Where it lacked the water data of the
* region (PAD-11C), the row follows the data instead: loading or asked for,
* the leg waits calmly; declined, failed or not to be had, it says why.
*/
function LegFailure({
code,
load,
disabled,
onDrawStraight,
onRetry,
onLoadRetry,
}: {
code: PaddleLegFailure;
load: PaddleRegionLoadState | undefined;
disabled: boolean;
onDrawStraight: () => void;
onRetry: () => void;
onLoadRetry: () => void;
}) {
const { t } = useTranslation();
if (isRegionLoadPending(load)) {
return <PaddleRegionLoadRow state={load} disabled={disabled} onDrawStraight={onDrawStraight} />;
}
return (
<PaddleLegFailureRow
code={code}
disabled={disabled}
onDrawStraight={onDrawStraight}
onRetry={onRetry}
message={regionLoadFailure(load, t)}
onLoadRetry={load?.state === 'failed' ? onLoadRetry : undefined}
/>
);
}
/**
* The rows under a waypoint for the leg that starts there: the via ferrata it
* crosses, a line drawn by hand, and on a paddle tour what the router found on
* the way (PAD-11) or why it could not plan it, plus the portage and lock rows.
*/
function WaypointLegRows({
planner,
point,
index,
crossings = [],
routedLegs,
paddleLayout,
editable,
portageRouting,
regionLoad,
}: {
planner: TourPlannerController;
point: WaypointPoint;
index: number;
crossings?: TourFerrataCrossing[];
routedLegs: TourPlannerController['paddleRoute'] | null;
paddleLayout: ReturnType<typeof paddleLayoutOfPoints> | null;
editable: boolean;
portageRouting: ReturnType<typeof usePortageRouting>;
regionLoad: ReturnType<typeof usePaddleRegionLoad>;
}) {
const failure = routedLegs?.failures.find((candidate) => candidate.index === index);
return (
<>
{crossings.map((crossing) => (
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
))}
{point.fixedLeg?.kind === 'free' && <FreeLegRow leg={point.fixedLeg} />}
{routedLegs && <PaddleLegNotes leg={routedLegs.legs[index]} />}
{failure && (
<LegFailure
code={failure.code}
load={regionLoad.byLeg.get(index)}
disabled={!editable}
onDrawStraight={() => planner.toggleWaypointFreeLeg(point.id)}
onRetry={() => planner.recomputeLeg(point.id)}
onLoadRetry={regionLoad.retry}
/>
)}
{paddleLayout && (
<PaddleRowExtras
planner={planner}
point={point}
index={index}
layout={paddleLayout}
editable={editable}
routing={portageRouting}
/>
)}
</>
);
}
export default function TourWaypointList({ export default function TourWaypointList({
planner, planner,
canEdit, canEdit,
@@ -489,34 +728,22 @@ export default function TourWaypointList({
// A hike keeps its start and end badges (undefined); a paddle tour names each point for the paddler. // A hike keeps its start and end badges (undefined); a paddle tour names each point for the paddler.
const paddleRoleOf = (id: string) => (paddleRoles ? (paddleRoles.get(id) ?? null) : undefined); const paddleRoleOf = (id: string) => (paddleRoles ? (paddleRoles.get(id) ?? null) : undefined);
const paddleLayout = planner.isPaddle ? paddleLayoutOfPoints(paddlePlanPoints(planner.waypoints)) : null; const paddleLayout = planner.isPaddle ? paddleLayoutOfPoints(paddlePlanPoints(planner.waypoints)) : null;
// PAD-03: portages the paddler sets follow the ways on land.
const portageRouting = usePortageRouting(planner, canEdit && !isSaving);
// The router's answer per leg, while it still belongs to these points (PAD-11).
const routedLegs = planner.isPaddle && planner.routeCurrent ? planner.paddleRoute : null;
// PAD-11C: where a leg lacks the water data of its region, TREK fetches it and plans the leg again.
const regionLoad = usePaddleRegionLoad(routedLegs?.failures, planner.retry);
const stopNumbers = stopNumbersOf(planner.waypoints);
const endDrag = () => { const endDrag = () => {
setDraggedWaypointId(null); setDraggedWaypointId(null);
setDropTarget(null); setDropTarget(null);
}; };
// One card like a day in the plan: the head counts the points, the rows sit below. // One card like a day in the plan: the head counts the points, the rows sit below.
const tools = canEdit && planner.waypoints.length > 0 && ( const tools = canEdit && planner.waypoints.length > 0 && <WaypointListTools planner={planner} isSaving={isSaving} />;
<span className="flex flex-none items-center gap-0.5"> // The way back of a loop is no stop of its own, and a shaping point no stop at all, so the count leaves them out.
<RouteToolButton const stops = stopNumbers.size - (planner.isLoop ? 1 : 0);
testId="tour-planner-reverse"
label={t('tours.planner.reverse')}
disabled={isSaving || planner.waypoints.length < 2}
onAct={planner.reverse}
>
<ArrowDownUp size={13} strokeWidth={2.2} />
</RouteToolButton>
<RouteToolButton
testId="tour-planner-return-to-start"
label={t(planner.isLoop ? 'tours.planner.alreadyLoop' : 'tours.planner.returnToStart')}
disabled={isSaving || planner.waypoints.length < 2 || planner.isLoop}
onAct={planner.returnToStart}
>
<Repeat size={13} strokeWidth={2.2} />
</RouteToolButton>
</span>
);
// The way back of a loop is no stop of its own, so the count leaves it out.
const stops = planner.waypoints.length - (planner.isLoop ? 1 : 0);
const card = (children: ReactNode) => ( const card = (children: ReactNode) => (
<RailCard <RailCard
icon={<MapPin size={14} strokeWidth={2} />} icon={<MapPin size={14} strokeWidth={2} />}
@@ -608,10 +835,11 @@ export default function TourWaypointList({
<GripVertical size={14} strokeWidth={1.8} /> <GripVertical size={14} strokeWidth={1.8} />
</span> </span>
<WaypointNumber <WaypointNumber
index={index} stop={stopNumbers.get(point.id) ?? index + 1}
selected={selected} selected={selected}
ferrata={ferrataEnds[point.id] ?? null} ferrata={ferrataEnds[point.id] ?? null}
loopEnd={loopEnd} loopEnd={loopEnd}
shaping={!!point.shaping}
/> />
<button <button
type="button" type="button"
@@ -638,30 +866,23 @@ export default function TourWaypointList({
)} )}
</div> </div>
</li> </li>
{(legCrossings.get(point.id) ?? []).map((crossing) => ( <WaypointLegRows
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
))}
{/* A paddle tour is drawn by hand throughout; the notice says it once instead of on every leg. */}
{point.fixedLeg?.kind === 'free' && !planner.isPaddle && <FreeLegRow leg={point.fixedLeg} />}
{paddleLayout && (
<PaddleRowExtras
planner={planner} planner={planner}
point={point} point={point}
index={index} index={index}
layout={paddleLayout} crossings={legCrossings.get(point.id)}
routedLegs={routedLegs}
paddleLayout={paddleLayout}
editable={canEdit && !isSaving} editable={canEdit && !isSaving}
portageRouting={portageRouting}
regionLoad={regionLoad}
/> />
)}
</Fragment> </Fragment>
); );
})} })}
</ol> </ol>
{canEdit && <AddPointRow disabled={isSaving} />} {canEdit && <AddPointRow disabled={isSaving} />}
{planner.isPaddle && ( <ShapeOnLineHint planner={planner} canEdit={canEdit} />
<div className="mt-2">
<TourNotice icon={<Info size={13} strokeWidth={2} />}>{t('tours.paddle.drawnByHand')}</TourNotice>
</div>
)}
</> </>
); );
} }
@@ -6,7 +6,7 @@ import {
type PaddleRole, type PaddleRole,
type PaddleTimeEstimate, type PaddleTimeEstimate,
} from '@trek/shared'; } from '@trek/shared';
import type { TourPlannerWaypoint } from './useTourPlanner'; import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
/** The planner's control points as the shared paddle plan reads them. */ /** The planner's control points as the shared paddle plan reads them. */
export function paddlePlanPoints(points: readonly TourPlannerWaypoint[]): PaddlePlanPoint[] { export function paddlePlanPoints(points: readonly TourPlannerWaypoint[]): PaddlePlanPoint[] {
@@ -26,15 +26,17 @@ export function paddleRolesById(points: readonly TourPlannerWaypoint[]): Map<str
/** /**
* PAD-07: the estimate of a paddle tour as planned, without its breaks (the * PAD-07: the estimate of a paddle tour as planned, without its breaks (the
* planner adds those as it does for a hike). A speed the paddler typed that * planner adds those as it does for a hike), with the routed length of each
* leg where the router planned it (PAD-11). A speed the paddler typed that
* does not hold leaves the water time unknown rather than wrong. * does not hold leaves the water time unknown rather than wrong.
*/ */
export function paddleEstimateOf( export function paddleEstimateOf(
points: readonly TourPlannerWaypoint[], points: readonly TourPlannerWaypoint[],
paddle: { effectiveSpeedKmh: number | null; portageSpeedKmh: number | null }, paddle: { effectiveSpeedKmh: number | null; portageSpeedKmh: number | null },
speedInvalid: boolean speedInvalid: boolean,
legLengths?: ReadonlyArray<number | null>
): PaddleTimeEstimate { ): PaddleTimeEstimate {
return estimatePaddleTime(paddleLayoutOfPoints(paddlePlanPoints(points)), { return estimatePaddleTime(paddleLayoutOfPoints(paddlePlanPoints(points), legLengths), {
paddleSpeedKmh: speedInvalid ? null : paddle.effectiveSpeedKmh, paddleSpeedKmh: speedInvalid ? null : paddle.effectiveSpeedKmh,
portageSpeedKmh: paddle.portageSpeedKmh, portageSpeedKmh: paddle.portageSpeedKmh,
breakMinutes: null, breakMinutes: null,
@@ -0,0 +1,214 @@
// FE-PADDLE-ROUTING-001 to FE-PADDLE-ROUTING-010: a paddle tour's route put together from the water router's legs (PAD-11).
import type { PaddleRouteLeg, PaddleRouteSegment } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo';
import {
adoptChangedPoints,
materializePaddleLegs,
planPaddleStep,
pruneAutoPoints,
routePaddleTour,
type PaddlePoint,
} from './paddleRouting';
/** The item at `i`, or a failure that names the index instead of a later undefined. */
function at<T>(list: readonly T[], i: number): T {
const item = list[i];
if (item === undefined) throw new Error(`nothing at ${i}`);
return item;
}
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: vi.fn(), forget: vi.fn() } }));
const KAYAK = { boat: 'kayak' as const, lakeStyle: 'direct' as const };
const A: PaddlePoint = { id: 'a', lat: 49.1, lng: 12.1 };
const B: PaddlePoint = { id: 'b', lat: 49.05, lng: 12.12 };
const C: PaddlePoint = { id: 'c', lat: 49.0, lng: 12.1 };
function segment(kind: PaddleRouteSegment['kind'], from: number, to: number, extra: Partial<PaddleRouteSegment> = {}) {
return { kind, from, to, name: null, source: null, osm_ref: null, grade: null, ...extra };
}
/** A routed leg through five points: water, a portage round a weir, water, a lock, water. */
const WEIR_AND_LOCK: PaddleRouteLeg = {
status: 'ok',
geometry: [
[49.1, 12.1],
[49.09, 12.11],
[49.08, 12.11],
[49.07, 12.115],
[49.06, 12.118],
[49.05, 12.12],
],
segments: [
segment('water', 0, 1),
segment('portage', 1, 2, { source: 'tagged' }),
segment('water', 2, 3),
segment('lock', 3, 4, { name: 'Schleuse' }),
segment('water', 4, 5),
],
distance_m: 6100,
snapped_m: { from: 0, to: 0 },
};
const straight = (from: PaddlePoint, to: PaddlePoint, distance = 1000): PaddleRouteLeg => ({
status: 'ok',
geometry: [
[from.lat, from.lng],
[to.lat, to.lng],
],
segments: [segment('water', 0, 1)],
distance_m: distance,
snapped_m: { from: 0, to: 0 },
});
let ids = 0;
const newId = () => `auto-${(ids += 1)}`;
beforeEach(() => {
ids = 0;
vi.mocked(paddleRouteRepo.plan).mockReset();
});
describe('routePaddleTour', () => {
it('FE-PADDLE-ROUTING-001: asks only for the legs without a line, and keeps a drawn or carried leg as it is', async () => {
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([straight(B, C, 5500)]);
const drawn = {
kind: 'free' as const,
geometry: [
[A.lat, A.lng],
[B.lat, B.lng],
] as [number, number][],
};
const route = await routePaddleTour([{ ...A, fixedLeg: drawn }, B, C], 7, KAYAK);
expect(vi.mocked(paddleRouteRepo.plan).mock.calls[0]?.[1]).toEqual([{ from: B, to: C }]);
expect(route.legs[0]).toBeNull();
expect(route.legs[1]?.status).toBe('ok');
expect(route.parts.map((part) => [part.free, !!part.land])).toEqual([
[true, false],
[false, false],
]);
expect(route.legLengths[1]).toBe(5500);
expect(route.distanceMeters).toBeCloseTo(at(route.legLengths, 0) + 5500);
expect(route.coordinates[0]).toEqual([A.lat, A.lng]);
expect(route.coordinates[route.coordinates.length - 1]).toEqual([C.lat, C.lng]);
});
it('FE-PADDLE-ROUTING-002: draws a portage as land, whitewater and a boat pass amber, a gap red, and merges the rest', async () => {
const marked: PaddleRouteLeg = {
...WEIR_AND_LOCK,
segments: [
segment('water', 0, 1),
segment('portage', 1, 2),
segment('rapids', 2, 3, { grade: 2 }),
segment('pass', 3, 4),
segment('gap', 4, 5),
],
};
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([marked]);
const route = await routePaddleTour([A, B], 7, KAYAK);
expect(route.parts.map((part) => part.land ?? part.mark ?? 'water')).toEqual(['water', true, 'rapids', 'gap']);
// Whitewater and the boat pass next to it are one amber line.
expect(route.parts[2]?.coordinates).toHaveLength(3);
});
it('FE-PADDLE-ROUTING-003: a leg the router could not plan stays a straight unrouted stretch, counted as a failure', async () => {
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([
straight(A, B),
{ status: 'failed', code: 'not_on_water', at: { lat: C.lat, lng: C.lng } },
]);
const route = await routePaddleTour([A, B, C], 7, KAYAK);
expect(route.failures).toEqual([{ index: 1, code: 'not_on_water', at: { lat: C.lat, lng: C.lng } }]);
expect(route.parts[route.parts.length - 1]).toMatchObject({
mark: 'unrouted',
coordinates: [
[B.lat, B.lng],
[C.lat, C.lng],
],
});
// The straight line counts in the length, so the time does not shrink while a leg waits.
expect(route.legLengths[1]).toBeGreaterThan(5000);
});
it('FE-PADDLE-ROUTING-004: a leg the answer leaves out is unrouted, never silently straight water', async () => {
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([]);
const route = await routePaddleTour([A, B], 7, KAYAK);
expect(route.failures).toEqual([{ index: 0, code: 'no_water_path', at: null }]);
});
it('FE-PADDLE-ROUTING-005: a tour with every leg drawn asks nothing', async () => {
const carried = {
kind: 'portage' as const,
geometry: [
[A.lat, A.lng],
[B.lat, B.lng],
] as [number, number][],
extra_minutes: null,
};
const route = await routePaddleTour([{ ...A, fixedLeg: carried }, B], 7, KAYAK);
expect(paddleRouteRepo.plan).not.toHaveBeenCalled();
expect(route.parts[0]?.land).toBe(true);
});
});
describe('automatic points', () => {
it("FE-PADDLE-ROUTING-006: sets a portage pair and a lock where the router found them, on the paddler's own leg", () => {
const points = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
expect(points.map((point) => point.id)).toEqual(['a', 'auto-1', 'auto-2', 'auto-3', 'b']);
expect(points[1]).toMatchObject({ lat: 49.09, lng: 12.11, fixedLeg: { kind: 'portage', extra_minutes: null } });
expect(points[2]).toMatchObject({ lat: 49.08, lng: 12.11 });
expect(points[2]?.fixedLeg).toBeUndefined();
expect(points[3]).toMatchObject({ lock: { wait_minutes: null, transit_minutes: null } });
expect(points[1]?.auto).toEqual({
from: { id: 'a', lat: A.lat, lng: A.lng },
to: { id: 'b', lat: B.lat, lng: B.lng },
});
});
it('FE-PADDLE-ROUTING-007: planning the same leg again keeps the ids, and a leg at an automatic point adds nothing', () => {
const once = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
const legs = once.slice(0, -1).map(() => WEIR_AND_LOCK);
const twice = materializePaddleLegs(once, { legs }, newId);
expect(twice).toEqual(once);
});
it('FE-PADDLE-ROUTING-008: drops the automatic points of a leg whose end moved or went, and keeps them otherwise', () => {
const points = materializePaddleLegs([A, B, C], { legs: [WEIR_AND_LOCK, straight(B, C)] }, newId);
expect(pruneAutoPoints(points)).toEqual(points);
const moved = points.map((point) => (point.id === 'b' ? { ...point, lat: point.lat + 0.01 } : point));
expect(pruneAutoPoints(moved).map((point) => point.id)).toEqual(['a', 'b', 'c']);
const shaped = [at(points, 0), { id: 's', lat: 49.095, lng: 12.105, shaping: true }, ...points.slice(1)];
expect(pruneAutoPoints(shaped).map((point) => point.id)).toEqual(['a', 's', 'b', 'c']);
});
it('FE-PADDLE-ROUTING-009: a point the paddler moves or edits is theirs; an untouched one stays automatic', () => {
const points = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
const edited = points.map((point) =>
point.id === 'auto-3' ? { ...point, lock: { wait_minutes: 20, transit_minutes: null } } : point
);
const adopted = adoptChangedPoints(points, edited);
expect(adopted.find((point) => point.id === 'auto-3')?.auto).toBeUndefined();
expect(adopted.find((point) => point.id === 'auto-1')?.auto).toBeDefined();
});
});
describe('planPaddleStep', () => {
it('FE-PADDLE-ROUTING-010: prunes first, then sets what the router found, then answers the route', async () => {
const signal = new AbortController().signal;
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([WEIR_AND_LOCK]);
const first = await planPaddleStep([A, B], 7, KAYAK, signal, newId);
expect('points' in first && first.points).toHaveLength(5);
const found = 'points' in first ? first.points : [];
vi.mocked(paddleRouteRepo.plan).mockResolvedValue(found.slice(0, -1).map(() => straight(A, B, 1500)));
const second = await planPaddleStep(found, 7, KAYAK, signal, newId);
expect('route' in second && second.route.legLengths).toHaveLength(4);
const stale = found.map((point) => (point.id === 'a' ? { ...point, lng: point.lng + 0.01 } : point));
vi.mocked(paddleRouteRepo.plan).mockClear();
const third = await planPaddleStep(stale, 7, KAYAK, signal, newId);
expect('points' in third && third.points.map((point) => point.id)).toEqual(['a', 'b']);
expect(paddleRouteRepo.plan).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,262 @@
import type { PaddleLock, PaddleRouteFailureCode, PaddleRouteLeg, TourFixedLeg } from '@trek/shared';
import { paddleRouteRepo, type PaddleLegAnswer, type PaddleRouteProfile } from '../../../repo/paddleRouteRepo';
import { haversineKm } from '../../../utils/geo';
import { lineLengthM } from './tourFixedLegs';
import type { TourRoutePart } from './tourRouting';
/**
* PAD-11: a paddle tour's route in the planner. A leg without a line of its
* own is planned on the water by the server's router, as a hike's legs are by
* Valhalla; a leg drawn by hand or carried as a portage keeps its line. Where
* the router carries round a weir or goes through a lock between two of the
* paddler's own points, the planner sets points of its own there (`auto`), so
* the portage and the lock count like any planned by hand: in the time, in the
* checklist, on the map. They are derived again whenever the points they came
* from change, and become the paddler's own once touched.
*/
/** Where an automatic point came from: the two points of the paddler's leg it was found on, as they were. */
export interface AutoBase {
from: { id: string; lat: number; lng: number };
to: { id: string; lat: number; lng: number };
}
export interface PaddlePoint {
id: string;
lat: number;
lng: number;
fixedLeg?: TourFixedLeg | null;
lock?: PaddleLock | null;
shaping?: boolean;
/** Set by the planner where the router found a portage or a lock; gone once the paddler touches the point. */
auto?: AutoBase;
}
export type PaddleLegFailure = PaddleRouteFailureCode | 'offline';
export interface PaddleTourRoute {
coordinates: [number, number][];
parts: TourRoutePart[];
distanceMeters: number;
/** Per leg (index of its first point): the router's answer, or null for a leg with a line of its own. */
legs: Array<PaddleLegAnswer | null>;
/** The routed length of every leg, metres, for the time estimate. */
legLengths: number[];
failures: Array<{ index: number; code: PaddleLegFailure; at: { lat: number; lng: number } | null }>;
}
/** What a leg holds when its answer is somehow missing: unrouted, never silently straight. */
const NOT_ASKED: PaddleLegAnswer = { status: 'failed', code: 'no_water_path', at: null };
/** Two points this close are one spot, in degrees (about ten centimetres). */
const SAME_SPOT = 1e-6;
function samePlace(a: { lat: number; lng: number }, b: { lat: number; lng: number }): boolean {
return Math.abs(a.lat - b.lat) < SAME_SPOT && Math.abs(a.lng - b.lng) < SAME_SPOT;
}
type PartKind = 'water' | 'rapids' | 'gap' | 'unrouted' | 'land' | 'manual';
function partOf(kind: PartKind, coordinates: [number, number][]): TourRoutePart {
if (kind === 'land') return { coordinates, free: false, land: true };
if (kind === 'manual') return { coordinates, free: true };
return { coordinates, free: false, ...(kind === 'water' ? {} : { mark: kind }) };
}
/** The parts of one routed leg: water as one line, carried and marked stretches as their own. */
function routedParts(leg: Extract<PaddleRouteLeg, { status: 'ok' }>): TourRoutePart[] {
return leg.segments.map((segment) => {
const line = leg.geometry.slice(segment.from, segment.to + 1) as [number, number][];
if (segment.kind === 'portage') return partOf('land', line);
if (segment.kind === 'rapids' || segment.kind === 'pass') return partOf('rapids', line);
if (segment.kind === 'gap') return partOf('gap', line);
return partOf('water', line);
});
}
/** Neighbouring parts drawn alike as one line, as the hike's route does. */
function mergeParts(parts: TourRoutePart[]): TourRoutePart[] {
const merged: TourRoutePart[] = [];
for (const part of parts) {
const previous = merged[merged.length - 1];
if (previous && previous.free === part.free && !!previous.land === !!part.land && previous.mark === part.mark) {
previous.coordinates = [...previous.coordinates, ...part.coordinates.slice(1)];
} else {
merged.push({ ...part, coordinates: [...part.coordinates] });
}
}
return merged;
}
/** Plans the legs that need it and puts the whole line together. */
export async function routePaddleTour(
points: readonly PaddlePoint[],
tripId: number | string,
profile: PaddleRouteProfile,
signal?: AbortSignal
): Promise<PaddleTourRoute> {
const routed = points.slice(0, -1).flatMap((point, index) => (point.fixedLeg ? [] : [index]));
const answers = routed.length
? await paddleRouteRepo.plan(
tripId,
routed.map((index) => ({ from: points[index], to: points[index + 1] })),
profile,
signal
)
: [];
const byLeg = new Map(routed.map((index, at) => [index, answers[at]]));
const parts: TourRoutePart[] = [];
const legs: PaddleTourRoute['legs'] = [];
const legLengths: number[] = [];
const failures: PaddleTourRoute['failures'] = [];
for (let index = 0; index < points.length - 1; index += 1) {
const point = points[index];
const next = points[index + 1];
const leg = point.fixedLeg;
if (leg) {
const line = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng]);
parts.push(partOf(leg.kind === 'portage' ? 'land' : 'manual', line));
legs.push(null);
legLengths.push(lineLengthM(line));
continue;
}
const answer = byLeg.get(index) ?? NOT_ASKED;
legs.push(answer);
if (answer.status === 'ok') {
parts.push(...routedParts(answer));
legLengths.push(answer.distance_m);
continue;
}
// A leg the router could not plan stays visible as what it is: a straight stretch nobody checked.
failures.push({ index, code: answer.code, at: answer.at });
parts.push(
partOf('unrouted', [
[point.lat, point.lng],
[next.lat, next.lng],
])
);
legLengths.push(haversineKm(point, next) * 1000);
}
const lines = mergeParts(parts);
const coordinates = lines.flatMap((part, index) => (index === 0 ? part.coordinates : part.coordinates.slice(1)));
return {
coordinates,
parts: lines,
distanceMeters: legLengths.reduce((sum, length) => sum + length, 0),
legs,
legLengths,
failures,
};
}
function baseOf(points: readonly PaddlePoint[], index: number): AutoBase {
const from = points[index];
const to = points[index + 1];
return { from: { id: from.id, lat: from.lat, lng: from.lng }, to: { id: to.id, lat: to.lat, lng: to.lng } };
}
/**
* The automatic points that no longer stand on the leg they were found on
* (an end of it moved, or went), dropped; the next plan finds them again
* where they belong.
*/
export function pruneAutoPoints<T extends PaddlePoint>(points: readonly T[]): T[] {
const own = points.filter((point) => !point.auto);
return points.filter((point) => {
const base = point.auto;
if (!base) return true;
const from = own.find((candidate) => candidate.id === base.from.id);
const to = own.find((candidate) => candidate.id === base.to.id);
if (!from || !to || !samePlace(from, base.from) || !samePlace(to, base.to)) return false;
return own.indexOf(to) === own.indexOf(from) + 1;
});
}
/**
* Sets the portages and locks the router found on the paddler's own legs as
* points. An existing automatic point at the same spot keeps its id, so
* planning the same leg again changes nothing.
*/
export function materializePaddleLegs<T extends PaddlePoint>(
points: readonly T[],
route: Pick<PaddleTourRoute, 'legs'>,
newId: () => string
): T[] {
const existing = points.filter((point) => point.auto);
const idAt = (lat: number, lng: number) => existing.find((point) => samePlace(point, { lat, lng }))?.id ?? newId();
const result: T[] = [];
points.forEach((point, index) => {
result.push(point);
const next = points[index + 1];
const answer = route.legs[index];
// Only a leg between two of the paddler's own points: a leg at an automatic point is part of one already set.
if (!next || point.auto || next.auto || !answer || answer.status !== 'ok') return;
const base = baseOf(points, index);
const last = answer.geometry.length - 1;
for (const segment of answer.segments) {
if (segment.kind === 'portage' && segment.from > 0 && segment.to < last) {
const line = answer.geometry.slice(segment.from, segment.to + 1) as [number, number][];
const [outLat, outLng] = line[0];
const [inLat, inLng] = line[line.length - 1];
result.push(
{
id: idAt(outLat, outLng),
lat: outLat,
lng: outLng,
auto: base,
fixedLeg: { kind: 'portage', geometry: line, extra_minutes: null },
} as unknown as T,
{ id: idAt(inLat, inLng), lat: inLat, lng: inLng, auto: base } as unknown as T
);
}
if (segment.kind === 'lock' && segment.from > 0 && segment.to < last) {
const [lat, lng] = answer.geometry[Math.round((segment.from + segment.to) / 2)];
result.push({
id: idAt(lat, lng),
lat,
lng,
auto: base,
lock: { wait_minutes: null, transit_minutes: null },
} as unknown as T);
}
}
});
return result;
}
/** A point the paddler changed is theirs from then on: it loses its automatic mark. */
export function adoptChangedPoints<T extends PaddlePoint>(before: readonly T[], after: readonly T[]): T[] {
const previous = new Map(before.map((point) => [point.id, point]));
return after.map((point) => {
if (!point.auto) return point;
const was = previous.get(point.id);
const same =
!!was &&
samePlace(was, point) &&
JSON.stringify(was.lock ?? null) === JSON.stringify(point.lock ?? null) &&
JSON.stringify(was.fixedLeg ?? null) === JSON.stringify(point.fixedLeg ?? null);
if (same) return point;
const { auto: _auto, ...own } = point;
return own as T;
});
}
/**
* One run of the paddle router over the points: either points to set first
* (automatic ones dropped from a leg that changed, or found on a planned
* leg), after which the planner runs again, or the finished route.
*/
export async function planPaddleStep<T extends PaddlePoint>(
points: readonly T[],
tripId: number | string,
profile: PaddleRouteProfile,
signal: AbortSignal,
newId: () => string
): Promise<{ points: T[] } | { route: PaddleTourRoute }> {
const pruned = pruneAutoPoints(points);
if (pruned.length !== points.length) return { points: pruned };
const route = await routePaddleTour(points, tripId, profile, signal);
// Points are only ever added: the same count means nothing new was found.
const materialized = materializePaddleLegs(points, route, newId);
return materialized.length !== points.length ? { points: materialized } : { route };
}
@@ -0,0 +1,34 @@
// FE-ROUTE-LEG-AT-001 to FE-ROUTE-LEG-AT-003: which leg a click on the drawn route lands in (P2A-03).
import { describe, expect, it } from 'vitest';
import { legStartAt } from './routeLegAt';
const A = { id: 'a', lat: 49.0, lng: 12.0 };
const B = { id: 'b', lat: 49.0, lng: 12.1 };
const C = { id: 'c', lat: 49.1, lng: 12.1 };
// The route bends between the points, as a river does.
const LINE: [number, number][] = [
[49.0, 12.0],
[49.01, 12.05],
[49.0, 12.1],
[49.05, 12.11],
[49.1, 12.1],
];
describe('legStartAt', () => {
it('FE-ROUTE-LEG-AT-001: a spot on the line belongs to the leg whose two points enclose it', () => {
expect(legStartAt([A, B, C], LINE, 49.009, 12.05)).toBe('a');
expect(legStartAt([A, B, C], LINE, 49.05, 12.11)).toBe('b');
});
it('FE-ROUTE-LEG-AT-002: a loop keeps its order, so a spot near the start on the way back is the last leg', () => {
const back = { id: 'back', lat: A.lat, lng: A.lng };
const loop: [number, number][] = [...LINE, [49.05, 12.02], [49.0, 12.0]];
expect(legStartAt([A, B, C, back], loop, 49.05, 12.02)).toBe('c');
});
it('FE-ROUTE-LEG-AT-003: without a line or a leg there is nothing to shape', () => {
expect(legStartAt([A, B], null, 49, 12.05)).toBeNull();
expect(legStartAt([A], LINE, 49, 12.05)).toBeNull();
expect(legStartAt([A, B], [[49, 12]], 49, 12.05)).toBeNull();
});
});
@@ -0,0 +1,55 @@
/**
* P2A-03: which leg of a tour a spot on its drawn route belongs to. Every
* waypoint is placed along the line (searching on from where the one before
* lay, so a loop or a route that passes a point twice keeps its order), then
* the spot; the leg is the one whose two points enclose it.
*/
/** Where a spot falls along a line, as the distance from its start in the line's own degrees (flat earth is plenty for ordering). */
function alongLine(
line: readonly [number, number][],
lat: number,
lng: number,
fromIndex: number
): { at: number; index: number } {
const cos = Math.cos((lat * Math.PI) / 180);
let travelled = 0;
let best = { at: 0, index: fromIndex, distance: Infinity };
for (let i = 0; i < line.length - 1; i += 1) {
const [aLat, aLng] = line[i];
const [bLat, bLng] = line[i + 1];
const dx = (bLng - aLng) * cos;
const dy = bLat - aLat;
const length = Math.hypot(dx, dy);
if (i >= fromIndex) {
const t =
length === 0 ? 0 : Math.max(0, Math.min(1, ((lng - aLng) * cos * dx + (lat - aLat) * dy) / (length * length)));
const distance = Math.hypot(aLng * cos + dx * t - lng * cos, aLat + dy * t - lat);
if (distance < best.distance) best = { at: travelled + length * t, index: i, distance };
}
travelled += length;
}
return { at: best.at, index: best.index };
}
/** The id of the waypoint that starts the leg holding the spot, or null when the route has no such leg. */
export function legStartAt(
points: ReadonlyArray<{ id: string; lat: number; lng: number }>,
line: readonly [number, number][] | null,
lat: number,
lng: number
): string | null {
if (!line || line.length < 2 || points.length < 2) return null;
const positions: number[] = [];
let from = 0;
for (const point of points) {
const placed = alongLine(line, point.lat, point.lng, from);
positions.push(placed.at);
from = placed.index;
}
const spot = alongLine(line, lat, lng, 0).at;
for (let i = 0; i < points.length - 1; i += 1) {
if (spot >= positions[i] && spot <= positions[i + 1]) return points[i].id;
}
return null;
}
@@ -5,8 +5,8 @@ import {
type PlannableTourType, type PlannableTourType,
type TourMaxHikingDifficulty, type TourMaxHikingDifficulty,
} from '@trek/shared'; } from '@trek/shared';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
import type { PaddleFieldValues } from './usePaddleFields'; import type { PaddleFieldValues } from './usePaddleFields';
import type { TourPlannerWaypoint } from './useTourPlanner';
/** /**
* The tour planner's draft as it is kept in local storage between visits, and * The tour planner's draft as it is kept in local storage between visits, and
@@ -15,7 +15,7 @@ import type { TourPlannerWaypoint } from './useTourPlanner';
* half read. * half read.
*/ */
export interface DraftPayload { export interface DraftPayload {
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
name: string; name: string;
description?: string; description?: string;
website?: string; website?: string;
@@ -30,7 +30,7 @@ export interface DraftPayload {
paddle?: PaddleFieldValues; paddle?: PaddleFieldValues;
} }
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8]; const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9];
function optionalString(value: unknown): boolean { function optionalString(value: unknown): boolean {
return value === undefined || typeof value === 'string'; return value === undefined || typeof value === 'string';
@@ -0,0 +1,83 @@
// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-005: the editor's waypoint model, shaping points and drawn legs (P2A-03, PAD-11).
import { describe, expect, it } from 'vitest';
import {
normalizePlannerWaypoints,
sameWaypointSnapshot,
tourWaypointKey,
withDrawnLegsRouted,
withShaping,
withShapingPoint,
type TourPlannerWaypoint,
} from './tourPlannerWaypoints';
const A: TourPlannerWaypoint = { id: 'a', lat: 49, lng: 12, role: 'start' };
const B: TourPlannerWaypoint = { id: 'b', lat: 49.01, lng: 12.01, role: 'via' };
const C: TourPlannerWaypoint = { id: 'c', lat: 49.02, lng: 12.02, role: 'end' };
describe('tourPlannerWaypoints', () => {
it('FE-TOUR-WAYPOINTS-001: a shaping point goes into the leg after the point named, and nowhere past the end', () => {
const shaped = withShapingPoint([A, B, C], 'b', { id: 's', lat: 49.015, lng: 12.015 });
expect(shaped.map((point) => point.id)).toEqual(['a', 'b', 's', 'c']);
expect(shaped[2]).toEqual({ id: 's', lat: 49.015, lng: 12.015, role: 'via', shaping: true });
const points = [A, B, C];
expect(withShapingPoint(points, 'c', { id: 's', lat: 0, lng: 0 })).toBe(points);
expect(withShapingPoint(points, 'missing', { id: 's', lat: 0, lng: 0 })).toBe(points);
});
it('FE-TOUR-WAYPOINTS-002: a stop becomes a shaping point and back; the ends always stay stops', () => {
const shaped = withShaping([A, B, C], 'b', true);
expect(shaped[1]?.shaping).toBe(true);
expect(withShaping(shaped, 'b', false)[1]).not.toHaveProperty('shaping');
expect(withShaping([A, B, C], 'a', true)[0]).toBe(A);
expect(withShaping([A, B, C], 'c', true)[2]).toBe(C);
});
it('FE-TOUR-WAYPOINTS-003: every drawn leg goes back to the router; a portage stays', () => {
const free = {
kind: 'free' as const,
geometry: [
[49, 12],
[49.01, 12.01],
] as [number, number][],
};
const carried = {
kind: 'portage' as const,
geometry: [
[49.01, 12.01],
[49.02, 12.02],
] as [number, number][],
extra_minutes: 5,
};
const routed = withDrawnLegsRouted([{ ...A, fixedLeg: free }, { ...B, fixedLeg: carried }, C]);
expect(routed[0]).not.toHaveProperty('fixedLeg');
expect(routed[1]?.fixedLeg).toEqual(carried);
});
it('FE-TOUR-WAYPOINTS-004: the key changes when a point shapes instead of stops, or a line gains a bend', () => {
const key = tourWaypointKey([A, B, C]);
expect(tourWaypointKey(withShaping([A, B, C], 'b', true))).not.toBe(key);
const two = {
kind: 'free' as const,
geometry: [
[49, 12],
[49.01, 12.01],
] as [number, number][],
};
const three = {
kind: 'free' as const,
geometry: [
[49, 12],
[49.005, 12.0],
[49.01, 12.01],
] as [number, number][],
};
expect(tourWaypointKey([{ ...A, fixedLeg: two }, B])).not.toBe(tourWaypointKey([{ ...A, fixedLeg: three }, B]));
});
it('FE-TOUR-WAYPOINTS-005: a point the router set is part of the snapshot, and roles follow the order', () => {
const auto = { from: { id: 'a', lat: 49, lng: 12 }, to: { id: 'c', lat: 49.02, lng: 12.02 } };
expect(sameWaypointSnapshot([A, B, C], [A, { ...B, auto }, C])).toBe(false);
expect(sameWaypointSnapshot([A, B, C], [A, B, C])).toBe(true);
expect(normalizePlannerWaypoints([C, A, B]).map((point) => point.role)).toEqual(['start', 'via', 'end']);
});
});
@@ -0,0 +1,119 @@
import type { PaddleLock, TourFixedLeg, TourWaypoint, TourWaypointRole } from '@trek/shared';
import type { AutoBase } from './paddleRouting';
import { keepJoinedFixedLegs } from './tourFixedLegs';
import { alignFreeLegs } from './tourRouteActions';
/** A control point of a planned tour, as the editor holds it. */
export interface TourPlannerWaypoint {
id: string;
lat: number;
lng: number;
role: TourWaypointRole;
/** The leg to the next waypoint as a fixed line (a via ferrata on the tour), not routed. */
fixedLeg?: TourFixedLeg | null;
/** PAD-07: a lock at this point of a paddle tour, with the times expected there. */
lock?: PaddleLock | null;
/** P2A-03: a shaping point, there only to steer the route; no stop. */
shaping?: boolean;
/** PAD-11: set where the router found a portage or a lock; gone once the paddler touches the point. */
auto?: AutoBase;
/** PAD-03: at the last point, the boat is carried on to the next point once it is set; never saved. */
carryNext?: boolean;
}
/** A saved tour's waypoints as the editor holds them, each under a fresh id. */
export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () => string): TourPlannerWaypoint[] {
return saved.map((point) => ({
id: newId(),
lat: point.lat,
lng: point.lng,
role: point.role,
...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}),
...(point.lock ? { lock: point.lock } : {}),
...(point.shaping ? { shaping: true } : {}),
}));
}
/**
* The editor's waypoints as they are saved, in their order: what the server
* keeps and nothing of the editor's own (ids, automatic marks, a portage
* waiting for its next point).
*/
export function savedWaypointsOf(points: readonly TourPlannerWaypoint[]): TourWaypoint[] {
return points.map((point, sequence) => ({
lat: point.lat,
lng: point.lng,
role: point.role,
sequence,
...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}),
...(point.lock ? { lock: point.lock } : {}),
...(point.shaping ? { shaping: true } : {}),
}));
}
/** Roles from the order; free legs follow their points, and a paddle tour keeps its portages and locks. */
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], paddle = false): TourPlannerWaypoint[] {
return keepJoinedFixedLegs(alignFreeLegs(points, paddle)).map((point, index) => ({
...point,
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
}));
}
function fixedLegKey(leg: TourFixedLeg | null | undefined): string {
if (!leg) return '';
return leg.kind === 'ferrata' ? `:${leg.ref}` : `:${leg.kind}:${leg.geometry.length}`;
}
/** What the route depends on: every point, where it is, the fixed line after it and whether it only shapes. */
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
return points
.map(
(point) =>
`${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${fixedLegKey(point.fixedLeg)}${point.shaping ? ':shape' : ''}`
)
.join('|');
}
/** The line, and what a paddler planned along it that draws no line: a portage's time, a lock, a point the router set. */
export function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
const extras = (points: TourPlannerWaypoint[]) =>
JSON.stringify(
points.map((point) => [
point.fixedLeg?.kind === 'portage' ? (point.fixedLeg.extra_minutes ?? null) : null,
point.lock ?? null,
!!point.auto,
!!point.carryNext,
])
);
return tourWaypointKey(a) === tourWaypointKey(b) && extras(a) === extras(b);
}
/** P2A-03: the points with a shaping point set into the leg after `afterId`; unchanged when there is no such leg. */
export function withShapingPoint(
points: TourPlannerWaypoint[],
afterId: string,
shape: Pick<TourPlannerWaypoint, 'id' | 'lat' | 'lng'>
): TourPlannerWaypoint[] {
const index = points.findIndex((point) => point.id === afterId);
if (index < 0 || index >= points.length - 1) return points;
return [...points.slice(0, index + 1), { ...shape, role: 'via', shaping: true }, ...points.slice(index + 1)];
}
/** P2A-03: a stop made a shaping point, or a shaping point a stop. The ends of a tour are always stops. */
export function withShaping(points: TourPlannerWaypoint[], id: string, shaping: boolean): TourPlannerWaypoint[] {
return points.map((point, index) => {
if (point.id !== id || index === 0 || index === points.length - 1) return point;
if (shaping) return { ...point, shaping: true };
const { shaping: _dropped, ...stop } = point;
return stop;
});
}
/** PAD-11: every leg drawn by hand handed back to the router. */
export function withDrawnLegsRouted(points: TourPlannerWaypoint[]): TourPlannerWaypoint[] {
return points.map((point) => {
if (point.fixedLeg?.kind !== 'free') return point;
const { fixedLeg: _drawn, ...routed } = point;
return routed;
});
}
@@ -108,10 +108,25 @@ describe('free legs (P2A-04)', () => {
expect(aligned.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]); expect(aligned.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
}); });
it('draws every leg by hand for a paddle tour', () => { it('routes a paddle tour on the water: a via ferrata goes back to the router, a leg drawn by hand follows its points', () => {
const aligned = alignFreeLegs([A, { ...B, fixedLeg: FERRATA }, C], true); const aligned = alignFreeLegs([A, { ...B, fixedLeg: FERRATA }, C], true);
expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]); expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual([undefined, undefined, undefined]);
expect(aligned[1].fixedLeg).toEqual(freeLegBetween(B, C)); const drawn = alignFreeLegs([{ ...A, fixedLeg: freeLegBetween(A, C) }, B, C], true);
expect(drawn[0].fixedLeg).toEqual(freeLegBetween(A, B));
});
it('keeps the shape of a drawn paddle leg while its ends still meet their points, and redraws it once one moves', () => {
const shaped = {
kind: 'free' as const,
geometry: [
[A.lat, A.lng],
[A.lat + 0.001, A.lng + 0.002],
[B.lat, B.lng],
] as [number, number][],
};
expect(alignFreeLegs([{ ...A, fixedLeg: shaped }, B], true)[0].fixedLeg).toEqual(shaped);
const moved = { ...B, lat: B.lat + 0.01 };
expect(alignFreeLegs([{ ...A, fixedLeg: shaped }, moved, C], true)[0].fixedLeg).toEqual(freeLegBetween(A, moved));
}); });
}); });
@@ -122,7 +137,31 @@ describe('portages and locks (PAD-03, PAD-07)', () => {
const carried = togglePortageLeg(alignFreeLegs([A, B, C], true), 'a'); const carried = togglePortageLeg(alignFreeLegs([A, B, C], true), 'a');
expect(carried[0].fixedLeg).toEqual(portageLegBetween(A, B)); expect(carried[0].fixedLeg).toEqual(portageLegBetween(A, B));
expect(togglePortageLeg(carried, 'a')[0].fixedLeg).toEqual(freeLegBetween(A, B)); expect(togglePortageLeg(carried, 'a')[0].fixedLeg).toEqual(freeLegBetween(A, B));
expect(togglePortageLeg(carried, 'c')).toEqual(carried); });
it('asks for a portage at the last point and draws it to the point set next', () => {
const asked = togglePortageLeg(alignFreeLegs([A, B], true), 'b');
expect(asked[1]).toMatchObject({ carryNext: true });
// Redrawn with no point after it, the ask waits.
expect(alignFreeLegs(asked, true)[1]).toMatchObject({ carryNext: true });
const placed = alignFreeLegs([...asked, C], true);
expect(placed[1].fixedLeg).toEqual(portageLegBetween(B, C));
expect(placed[1]).not.toHaveProperty('carryNext');
// A second press takes the ask back, and a hike forgets it.
expect(togglePortageLeg(asked, 'b')[1]).not.toHaveProperty('carryNext');
expect(alignFreeLegs([...asked, C])[1]).not.toHaveProperty('carryNext');
});
it('keeps a portage that follows a way while its ends stay, and draws it straight once one moves', () => {
const geometry: [number, number][] = [
[A.lat, A.lng],
[47.004, 11.006],
[B.lat, B.lng],
];
const points: RoutePoint[] = [{ ...A, fixedLeg: { ...portageLegBetween(A, B), geometry } }, B, C];
expect(alignFreeLegs(points, true)[0].fixedLeg).toEqual(points[0].fixedLeg);
const moved = alignFreeLegs([points[0], { ...B, lat: 47.012 }, C], true);
expect(moved[0].fixedLeg).toEqual(portageLegBetween(A, { lat: 47.012, lng: B.lng }));
}); });
it('keeps a portage and its time when a paddle tour is redrawn, and moves the time to the leg that leads it', () => { it('keeps a portage and its time when a paddle tour is redrawn, and moves the time to the leg that leads it', () => {
@@ -14,6 +14,12 @@ export interface RoutePoint {
lng: number; lng: number;
fixedLeg?: TourFixedLeg | null; fixedLeg?: TourFixedLeg | null;
lock?: PaddleLock | null; lock?: PaddleLock | null;
/**
* PAD-03: the boat is carried on from this point, the last of the tour so
* far; the portage is drawn to the next point once it is set. The planner's
* own, never saved.
*/
carryNext?: boolean;
} }
/** Closer than this, in degrees, two waypoints stand on the same spot (about a centimetre). */ /** Closer than this, in degrees, two waypoints stand on the same spot (about a centimetre). */
@@ -114,25 +120,46 @@ function extrasOnFirstLeg<T extends RoutePoint>(points: T[]): T[] {
return points; return points;
} }
/** A line still joins its points when its ends lie this close to them, in degrees (about two metres). */
const JOINS_DEG = 2e-5;
function stillJoins(leg: TourFixedLeg, from: { lat: number; lng: number }, to: { lat: number; lng: number }): boolean {
const first = leg.geometry[0];
const last = leg.geometry[leg.geometry.length - 1];
const near = (end: readonly unknown[] | undefined, point: { lat: number; lng: number }) =>
!!end && Math.abs(Number(end[0]) - point.lat) < JOINS_DEG && Math.abs(Number(end[1]) - point.lng) < JOINS_DEG;
return near(first, from) && near(last, to);
}
/** /**
* Every hand-drawn leg, and every portage, redrawn straight to the waypoint * Every hand-drawn leg redrawn straight to the waypoint that now follows it,
* that now follows it, so moving a point drags them along instead of tearing * so moving a point drags it along instead of tearing it off; a leg on the
* them off. A leg on the last point leads nowhere and goes, and so does a lock * last point leads nowhere and goes. On a hike a portage and a lock are a
* nobody paddles to on a tour that is no paddle tour. With `allFree` every * paddler's and go. On a paddle tour (`paddle`) a via ferrata is a hiker's
* leg that is not a portage becomes hand-drawn, the way a paddle tour is planned. * and goes back to the router; a portage and a lock stay, a portage is
* dragged along like a drawn leg, and a portage or drawn leg whose line has
* a shape of its own (a portage way the router followed) keeps it while its
* ends still meet their points. A leg without a line stays routed. A portage
* asked for at the last point of a paddle tour becomes one once a point follows.
*/ */
export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], allFree = false): T[] { export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], paddle = false): T[] {
const aligned = points.map((point, index) => { const aligned = points.map((current, index) => {
const next = points[index + 1]; const next = points[index + 1];
const leg = point.fixedLeg; const { carryNext, ...own } = current;
const kept = allFree ? point : dropPaddleOnly(point); if (carryNext && paddle) {
const free = allFree || leg?.kind === 'free'; if (!next) return current;
if (!free && !(allFree && isPortage(leg))) return kept; return { ...own, fixedLeg: portageLegBetween(current, next) } as T;
}
const point = (carryNext ? own : current) as T;
const kept = paddle ? dropHikeOnly(point) : dropPaddleOnly(point);
const leg = kept.fixedLeg;
const follows = leg?.kind === 'free' || (paddle && isPortage(leg));
if (!follows || !leg) return kept;
if (!next) { if (!next) {
if (!kept.fixedLeg) return kept;
const { fixedLeg: _dropped, ...rest } = kept; const { fixedLeg: _dropped, ...rest } = kept;
return rest as T; return rest as T;
} }
if (paddle && leg.geometry.length > 2 && stillJoins(leg, point, next)) return kept;
return { return {
...kept, ...kept,
fixedLeg: isPortage(leg) fixedLeg: isPortage(leg)
@@ -140,7 +167,14 @@ export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], allFre
: freeLegBetween(point, next), : freeLegBetween(point, next),
}; };
}); });
return allFree ? extrasOnFirstLeg(aligned) : aligned; return paddle ? extrasOnFirstLeg(aligned) : aligned;
}
/** A boat climbs no via ferrata: its leg goes back to the router, on the water. */
function dropHikeOnly<T extends RoutePoint>(point: T): T {
if (point.fixedLeg?.kind !== 'ferrata') return point;
const { fixedLeg: _ferrata, ...routed } = point;
return routed as T;
} }
/** A hike carries no boat: a portage turns back into a routed leg and a lock goes. */ /** A hike carries no boat: a portage turns back into a routed leg and a lock goes. */
@@ -151,13 +185,21 @@ function dropPaddleOnly<T extends RoutePoint>(point: T): T {
return plain as T; return plain as T;
} }
/** PAD-03: the leg after this point carried as a portage, or paddled again. */ /**
* PAD-03: the leg after this point carried as a portage, or paddled again. At
* the last point there is no leg yet: the portage waits for the next point.
*/
export function togglePortageLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] { export function togglePortageLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
const index = points.findIndex((point) => point.id === id); const index = points.findIndex((point) => point.id === id);
const point = points[index]; const point = points[index];
const next = points[index + 1]; const next = points[index + 1];
if (!point || !next) return [...points]; if (!point) return [...points];
const copy = [...points]; const copy = [...points];
if (!next) {
const { carryNext, ...own } = point;
copy[index] = (carryNext ? own : { ...point, carryNext: true }) as T;
return copy;
}
copy[index] = { copy[index] = {
...point, ...point,
fixedLeg: isPortage(point.fixedLeg) ? freeLegBetween(point, next) : portageLegBetween(point, next), fixedLeg: isPortage(point.fixedLeg) ? freeLegBetween(point, next) : portageLegBetween(point, next),
@@ -189,14 +231,15 @@ export function setWaypointLock<T extends RoutePoint>(points: readonly T[], id:
} }
/** /**
* The leg after this waypoint drawn by hand, or routed again. A via ferrata * The leg after this waypoint drawn by hand, or routed again (on a paddle
* keeps its line; the last point has no leg to change. * tour: on the water). A via ferrata and a portage keep their line; the last
* point has no leg to change.
*/ */
export function toggleFreeLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] { export function toggleFreeLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
const index = points.findIndex((point) => point.id === id); const index = points.findIndex((point) => point.id === id);
const point = points[index]; const point = points[index];
const next = points[index + 1]; const next = points[index + 1];
if (!point || !next || point.fixedLeg?.kind === 'ferrata') return [...points]; if (!point || !next || point.fixedLeg?.kind === 'ferrata' || isPortage(point.fixedLeg)) return [...points];
const copy = [...points]; const copy = [...points];
if (point.fixedLeg?.kind === 'free') { if (point.fixedLeg?.kind === 'free') {
const { fixedLeg: _dropped, ...rest } = point; const { fixedLeg: _dropped, ...rest } = point;
@@ -0,0 +1,47 @@
// FE-TOUR-ROUTE-STYLE-001 to FE-TOUR-ROUTE-STYLE-003: how the planner map draws the parts of a tour (PAD-03, PAD-11).
import { describe, expect, it } from 'vitest';
import { tourRouteStyle } from './tourRouteStyle';
import type { TourRoutePart } from './tourRouting';
const PAINT = { portage: 'brown', rapids: 'amber', gap: 'red', casing: 'white' };
const line: [number, number][] = [
[49, 12],
[49.01, 12.01],
];
const part = (extra: Partial<TourRoutePart> = {}): TourRoutePart => ({ coordinates: line, free: false, ...extra });
describe('tourRouteStyle', () => {
it('FE-TOUR-ROUTE-STYLE-001: a routed tour is one plain line, and no parts draw nothing', () => {
expect(tourRouteStyle([part(), part()], true, PAINT)).toEqual({ walking: null, colors: null });
expect(tourRouteStyle(null, true, PAINT)).toEqual({ walking: null, colors: null });
});
it('FE-TOUR-ROUTE-STYLE-002: a paddle tour dots and colours what no router planned and what the paddler should see', () => {
const style = tourRouteStyle(
[
part(),
part({ land: true }),
part({ mark: 'rapids' }),
part({ mark: 'gap' }),
part({ mark: 'unrouted' }),
part({ free: true }),
],
true,
PAINT
);
expect(style.walking).toEqual([false, true, false, true, true, true]);
expect(style.colors).toEqual([
undefined,
{ line: 'brown', casing: 'white' },
{ line: 'amber', casing: 'white' },
{ line: 'red', casing: 'white' },
{ line: 'red', casing: 'white' },
undefined,
]);
});
it('FE-TOUR-ROUTE-STYLE-003: a hike dots its hand-drawn stretches and keeps its own colour', () => {
const style = tourRouteStyle([part(), part({ free: true })], false, PAINT);
expect(style).toEqual({ walking: [false, true], colors: null });
});
});
@@ -1,36 +1,47 @@
import type { TourRoutePart } from './tourRouting'; import type { TourRoutePart } from './tourRouting';
/** /**
* PAD-03: a portage on the map, in an earth brown over a rim in the map's own * PAD-03, PAD-11: the paddle paints of the planner map, resolved from the
* white, dotted the way the map draws a walk: the boat is carried there, not * paddle tokens (`usePaddlePaint`): a portage in earth brown, whitewater and
* paddled. The colours come resolved from the paddle tokens (`usePaddlePaint`). * boat passes in amber, a gap in the data or a leg no route was found for in
* red, each over a rim in the map's own white.
*/ */
export interface PortagePaint { export interface RoutePaint {
line: string; portage: string;
rapids: string;
gap: string;
casing: string; casing: string;
} }
export interface TourRouteStyle { export interface TourRouteStyle {
/** Per line: dotted (a hand-drawn stretch of a hike, a portage) or solid. */ /** Per line: dotted (a stretch nobody routed: drawn by hand, carried, or a gap) or solid. */
walking: boolean[] | null; walking: boolean[] | null;
/** Per line: the portage brown, or the route's own colour where undefined. */ /** Per line: a paddle paint, or the route's own colour where undefined. */
colors: ({ line: string; casing: string } | undefined)[] | null; colors: ({ line: string; casing: string } | undefined)[] | null;
} }
function paddleColor(part: TourRoutePart, paint: RoutePaint): { line: string; casing: string } | undefined {
if (part.land) return { line: paint.portage, casing: paint.casing };
if (part.mark === 'rapids') return { line: paint.rapids, casing: paint.casing };
if (part.mark === 'gap' || part.mark === 'unrouted') return { line: paint.gap, casing: paint.casing };
return undefined;
}
/** /**
* How the planner map draws the parts of a tour being edited. A hike draws * How the planner map draws the parts of a tour being edited. What no router
* what no router planned dotted, so a routed path and a hand-drawn one never * planned is dotted, so a routed path and a hand-drawn one never look alike:
* look alike. A paddle tour is drawn by hand throughout, so its water is the * a hike's hand-drawn stretches, a paddle tour's portages, gaps, unrouted legs
* plain line and only its portages stand out, dotted and brown. * and stretches drawn by hand. A paddle tour also colours what the paddler
* should look at.
*/ */
export function tourRouteStyle( export function tourRouteStyle(
parts: readonly TourRoutePart[] | null, parts: readonly TourRoutePart[] | null,
isPaddle: boolean, isPaddle: boolean,
portage: PortagePaint paint: RoutePaint
): TourRouteStyle { ): TourRouteStyle {
if (!parts?.length) return { walking: null, colors: null }; if (!parts?.length) return { walking: null, colors: null };
const walking = parts.map((part) => (isPaddle ? !!part.land : part.free)); const walking = parts.map((part) => part.free || !!part.land || part.mark === 'gap' || part.mark === 'unrouted');
const colors = parts.map((part) => (part.land ? { line: portage.line, casing: portage.casing } : undefined)); const colors = parts.map((part) => (isPaddle ? paddleColor(part, paint) : undefined));
return { return {
walking: walking.some(Boolean) ? walking : null, walking: walking.some(Boolean) ? walking : null,
colors: colors.some(Boolean) ? colors : null, colors: colors.some(Boolean) ? colors : null,
@@ -9,6 +9,12 @@ export interface TourRoutePart {
free: boolean free: boolean
/** PAD-03: carried over land, a portage of a paddle tour; absent on water and on a hike. */ /** PAD-03: carried over land, a portage of a paddle tour; absent on water and on a hike. */
land?: boolean land?: boolean
/**
* PAD-11: on a routed paddle tour, a stretch the paddler should look at:
* whitewater or a boat pass, a gap in the data the router bridged, or a leg
* it could not plan (drawn straight, and nothing checked it).
*/
mark?: 'rapids' | 'gap' | 'unrouted'
} }
export interface TourRouteResult { export interface TourRouteResult {
@@ -1,6 +1,7 @@
import { import {
recommendedPaddleSpeedKmh, recommendedPaddleSpeedKmh,
type PaddleBoatType, type PaddleBoatType,
type PaddleLakeStyle,
type PaddleWaterContext, type PaddleWaterContext,
type TourListItem, type TourListItem,
} from '@trek/shared'; } from '@trek/shared';
@@ -13,6 +14,8 @@ export interface PaddleFieldValues {
speedKmh: number | null; speedKmh: number | null;
/** PAD-07: the carrying speed the paddler chose, km/h; null while the starting value applies. Absent in an older draft. */ /** PAD-07: the carrying speed the paddler chose, km/h; null while the starting value applies. Absent in an older draft. */
portageSpeedKmh?: number | null; portageSpeedKmh?: number | null;
/** PAD-11: how lakes are routed; null (or absent, in an older draft) is straight over. */
lakeStyle?: PaddleLakeStyle | null;
} }
export const NO_PADDLE_FIELDS: PaddleFieldValues = { export const NO_PADDLE_FIELDS: PaddleFieldValues = {
@@ -20,17 +23,22 @@ export const NO_PADDLE_FIELDS: PaddleFieldValues = {
waterContext: null, waterContext: null,
speedKmh: null, speedKmh: null,
portageSpeedKmh: null, portageSpeedKmh: null,
lakeStyle: null,
}; };
/** The paddle fields of a saved tour, or none for a hike or a list cached before they existed. */ /** The paddle fields of a saved tour, or none for a hike or a list cached before they existed. */
export function paddleFieldsOf( export function paddleFieldsOf(
tour: Pick<TourListItem, 'boat_type' | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh'> tour: Pick<
TourListItem,
'boat_type' | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh' | 'paddle_lake_style'
>
): PaddleFieldValues { ): PaddleFieldValues {
return { return {
boatType: tour.boat_type ?? null, boatType: tour.boat_type ?? null,
waterContext: tour.water_context ?? null, waterContext: tour.water_context ?? null,
speedKmh: tour.paddle_speed_kmh ?? null, speedKmh: tour.paddle_speed_kmh ?? null,
portageSpeedKmh: tour.portage_speed_kmh ?? null, portageSpeedKmh: tour.portage_speed_kmh ?? null,
lakeStyle: tour.paddle_lake_style ?? null,
}; };
} }
@@ -51,7 +59,8 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
merged.boatType === values.boatType && merged.boatType === values.boatType &&
merged.waterContext === values.waterContext && merged.waterContext === values.waterContext &&
Object.is(merged.speedKmh, values.speedKmh) && Object.is(merged.speedKmh, values.speedKmh) &&
Object.is(merged.portageSpeedKmh ?? null, values.portageSpeedKmh ?? null); Object.is(merged.portageSpeedKmh ?? null, values.portageSpeedKmh ?? null) &&
(merged.lakeStyle ?? null) === (values.lakeStyle ?? null);
if (!same && onEdit()) setValues(merged); if (!same && onEdit()) setValues(merged);
}, },
[onEdit, values] [onEdit, values]
@@ -61,6 +70,7 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
return { return {
...values, ...values,
portageSpeedKmh: values.portageSpeedKmh ?? null, portageSpeedKmh: values.portageSpeedKmh ?? null,
lakeStyle: values.lakeStyle ?? 'direct',
recommendedSpeedKmh: recommended, recommendedSpeedKmh: recommended,
/** The speed the time is worked out with: the chosen one, else the boat's suggestion. */ /** The speed the time is worked out with: the chosen one, else the boat's suggestion. */
effectiveSpeedKmh: values.speedKmh ?? recommended, effectiveSpeedKmh: values.speedKmh ?? recommended,
@@ -69,6 +79,8 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
setSpeedKmh: useCallback((speedKmh: number | null) => edit({ speedKmh }), [edit]), setSpeedKmh: useCallback((speedKmh: number | null) => edit({ speedKmh }), [edit]),
/** PAD-07: the carrying speed, or back to the starting value with null. */ /** PAD-07: the carrying speed, or back to the starting value with null. */
setPortageSpeedKmh: useCallback((portageSpeedKmh: number | null) => edit({ portageSpeedKmh }), [edit]), setPortageSpeedKmh: useCallback((portageSpeedKmh: number | null) => edit({ portageSpeedKmh }), [edit]),
/** PAD-11: straight over the lakes, or along their shores. */
setLakeStyle: useCallback((lakeStyle: PaddleLakeStyle) => edit({ lakeStyle }), [edit]),
/** Back to the boat's suggestion, by choice. */ /** Back to the boat's suggestion, by choice. */
adoptRecommendedSpeed: useCallback(() => edit({ speedKmh: null }), [edit]), adoptRecommendedSpeed: useCallback(() => edit({ speedKmh: null }), [edit]),
/** Loaded from a tour or a draft, or cleared; not an edit. */ /** Loaded from a tour or a draft, or cleared; not an edit. */
@@ -0,0 +1,71 @@
import { useCallback, type RefObject } from 'react';
import { randomId } from '../../../utils/randomId';
import { withDrawnLegsRouted, withShaping, withShapingPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
type Commit = (change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => void;
/**
* P2A-03, P2A-05 and PAD-11: the editor's tools for the line between the
* stops. A shaping point steers the route without being a stop, a routed leg
* can be planned afresh, and a paddle tour's legs drawn by hand go back to the
* router in one step. Each change is one step of the history, through the
* planner's own commit; nothing here plans a route itself.
*/
export function useRouteShaping({
commit,
waypointsRef,
mayEdit,
isPaddle,
retry,
forgetLeg,
}: {
commit: Commit;
waypointsRef: RefObject<TourPlannerWaypoint[]>;
/** Whether the route may change now: the permission held and no save under way. */
mayEdit: () => boolean;
isPaddle: () => boolean;
/** Plans the route again as it stands. */
retry: () => void;
/** Drops the answer kept for a paddle leg, so the router is asked again. */
forgetLeg: (from: TourPlannerWaypoint, to: TourPlannerWaypoint) => void;
}) {
/** A shaping point into the leg after `afterId`; its id, or null when there is no such leg. */
const insertShapingPoint = useCallback(
(afterId: string, lat: number, lng: number) => {
if (!mayEdit() || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
const points = waypointsRef.current;
const shape = { id: randomId(), lat, lng };
if (withShapingPoint(points, afterId, shape) === points) return null;
commit((current) => withShapingPoint(current, afterId, shape));
return shape.id;
},
[commit, mayEdit, waypointsRef]
);
const setWaypointShaping = useCallback(
(id: string, shaping: boolean) => {
if (mayEdit()) commit((current) => withShaping(current, id, shaping));
},
[commit, mayEdit]
);
const routeDrawnLegsOnWater = useCallback(() => {
if (mayEdit() && isPaddle()) commit(withDrawnLegsRouted);
}, [commit, isPaddle, mayEdit]);
/** The routed leg after this waypoint planned afresh, past any answer kept from before. */
const recomputeLeg = useCallback(
(id: string) => {
const points = waypointsRef.current;
const index = points.findIndex((point) => point.id === id);
const point = points[index];
const next = points[index + 1];
if (!point || !next || point.fixedLeg) return;
if (isPaddle()) forgetLeg(point, next);
retry();
},
[forgetLeg, isPaddle, retry, waypointsRef]
);
return { insertShapingPoint, setWaypointShaping, routeDrawnLegsOnWater, recomputeLeg };
}
@@ -0,0 +1,26 @@
import { useCallback } from 'react';
import { legStartAt } from './routeLegAt';
import type { TourPlannerController } from './useTourPlanner';
/**
* P2A-03: a click on the route being edited sets a shaping point where it
* landed, in the leg that runs there, and selects it, ready to be dragged
* where the route should go. Undefined while the points cannot be moved, or
* while a click on the line picks a spot for something else.
*/
export function useShapingClick(
planner: Pick<TourPlannerController, 'waypoints' | 'route' | 'insertShapingPoint' | 'setSelectedWaypointId'>,
{ editable, picking }: { editable: boolean; picking: boolean }
): ((lat: number, lng: number) => void) | undefined {
const { waypoints, route, insertShapingPoint, setSelectedWaypointId } = planner;
const shapeAt = useCallback(
(lat: number, lng: number) => {
const after = legStartAt(waypoints, route, lat, lng);
if (!after) return;
const id = insertShapingPoint(after, lat, lng);
if (id) setSelectedWaypointId(id);
},
[insertShapingPoint, route, setSelectedWaypointId, waypoints]
);
return editable && !picking ? shapeAt : undefined;
}
@@ -1,16 +1,20 @@
import { act, renderHook } from '@testing-library/react'; import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({ const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, paddlePlan, paddleForget } =
vi.hoisted(() => ({
routeWalkingTour: vi.fn(), routeWalkingTour: vi.fn(),
enrichTourElevations: vi.fn(), enrichTourElevations: vi.fn(),
createTour: vi.fn(), createTour: vi.fn(),
detailTour: vi.fn(), detailTour: vi.fn(),
updateTour: vi.fn(), updateTour: vi.fn(),
})); paddlePlan: vi.fn(),
paddleForget: vi.fn(),
}));
vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations })); vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } })); vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: paddlePlan, forget: paddleForget } }));
vi.mock('../useTourPermissions', () => ({ vi.mock('../useTourPermissions', () => ({
useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({ useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({
canEdit, canEdit,
@@ -20,6 +24,12 @@ vi.mock('../useTourPermissions', () => ({
import { useTourPlanner } from './useTourPlanner'; import { useTourPlanner } from './useTourPlanner';
/** The value, or a failure saying it is missing instead of a later undefined. */
function must<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error('expected a value');
return value;
}
const routed = { const routed = {
coordinates: [ coordinates: [
[48, 11], [48, 11],
@@ -64,6 +74,20 @@ function plannerWith(points: Array<[number, number]>) {
return hook; return hook;
} }
type LegAsked = { from: { lat: number; lng: number }; to: { lat: number; lng: number } };
/** The water router's answer for every leg asked: a straight stretch on the water, 1.2 km long. */
const onWater = (legs: LegAsked[]) =>
legs.map(({ from, to }) => ({
status: 'ok' as const,
geometry: [
[from.lat, from.lng],
[to.lat, to.lng],
],
segments: [{ kind: 'water' as const, from: 0, to: 1 }],
distance_m: 1200,
snapped_m: { from: 0, to: 0 },
}));
const coordinatesOf = (points: Array<{ lat: number; lng: number }>) => points.map((point) => [point.lat, point.lng]); const coordinatesOf = (points: Array<{ lat: number; lng: number }>) => points.map((point) => [point.lat, point.lng]);
describe('useTourPlanner route tools', () => { describe('useTourPlanner route tools', () => {
@@ -71,6 +95,8 @@ describe('useTourPlanner route tools', () => {
vi.useFakeTimers(); vi.useFakeTimers();
localStorage.clear(); localStorage.clear();
routeWalkingTour.mockReset().mockResolvedValue(routed); routeWalkingTour.mockReset().mockResolvedValue(routed);
paddlePlan.mockReset().mockImplementation(async (_trip: number, legs: LegAsked[]) => onWater(legs));
paddleForget.mockReset();
enrichTourElevations.mockReset().mockResolvedValue(enriched); enrichTourElevations.mockReset().mockResolvedValue(enriched);
createTour.mockReset(); createTour.mockReset();
detailTour.mockReset(); detailTour.mockReset();
@@ -221,7 +247,7 @@ describe('useTourPlanner route tools', () => {
}); });
describe('paddle tours (PAD-01)', () => { describe('paddle tours (PAD-01)', () => {
it('draws every leg by hand once paddle is chosen, and one undo takes the type and the legs back', () => { it('plans every leg on the water once paddle is chosen, and one undo takes the type back', async () => {
const { result } = plannerWith([ const { result } = plannerWith([
[48, 11], [48, 11],
[48.01, 11.02], [48.01, 11.02],
@@ -229,9 +255,22 @@ describe('useTourPlanner route tools', () => {
]); ]);
act(() => result.current.setTourType('kayak')); act(() => result.current.setTourType('kayak'));
expect(result.current.isPaddle).toBe(true); expect(result.current.isPaddle).toBe(true);
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]); expect(result.current.waypoints.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
routeWalkingTour.mockClear();
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
expect(routeWalkingTour).not.toHaveBeenCalled();
expect(paddlePlan).toHaveBeenLastCalledWith(
31,
expect.any(Array),
{ boat: 'kayak', lakeStyle: 'direct' },
expect.any(AbortSignal)
);
expect(result.current.status).toBe('ready');
expect(result.current.paddleRoute?.legLengths).toEqual([1200, 1200]);
act(() => result.current.addWaypoint(48.03, 11.01)); act(() => result.current.addWaypoint(48.03, 11.01));
expect(result.current.waypoints[2].fixedLeg?.kind).toBe('free'); expect(result.current.waypoints[2].fixedLeg).toBeUndefined();
act(() => result.current.undo()); act(() => result.current.undo());
act(() => result.current.undo()); act(() => result.current.undo());
expect(result.current.tourType).toBe('hike'); expect(result.current.tourType).toBe('hike');
@@ -240,7 +279,6 @@ describe('useTourPlanner route tools', () => {
it('holds Save back until a boat and the water are chosen, then saves them without a hiking limit', async () => { it('holds Save back until a boat and the water are chosen, then saves them without a hiking limit', async () => {
createTour.mockResolvedValue({ tour: { ...savedTour, tour_type: 'kayak' }, waypoints: [] }); createTour.mockResolvedValue({ tour: { ...savedTour, tour_type: 'kayak' }, waypoints: [] });
routeWalkingTour.mockResolvedValue({ ...routed, parts: [{ ...routed.parts[0], free: true }] });
const { result } = plannerWith([ const { result } = plannerWith([
[48, 11], [48, 11],
[48.01, 11.02], [48.01, 11.02],
@@ -264,11 +302,14 @@ describe('useTourPlanner route tools', () => {
boat_type: 'canoe', boat_type: 'canoe',
water_context: 'lake', water_context: 'lake',
paddle_speed_kmh: null, paddle_speed_kmh: null,
paddle_lake_style: 'direct',
max_hiking_difficulty: null, max_hiking_difficulty: null,
}); });
// A paddle tour carries no heights to look up.
expect(enrichTourElevations).not.toHaveBeenCalled();
}); });
it('times the water at the boat speed, keeps a chosen speed when the boat changes, and takes the suggestion only on purpose', async () => { it('times the water by the routed length at the boat speed, keeps a chosen speed when the boat changes, and takes the suggestion only on purpose', async () => {
const { result } = plannerWith([ const { result } = plannerWith([
[48, 11], [48, 11],
[48.01, 11.02], [48.01, 11.02],
@@ -278,13 +319,13 @@ describe('useTourPlanner route tools', () => {
await act(async () => { await act(async () => {
await vi.advanceTimersByTimeAsync(450); await vi.advanceTimersByTimeAsync(450);
}); });
const distanceKm = result.current.routeAnalysis?.distanceKm ?? 0; // 1.2 km on the water the router found, at 4 km/h.
expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 4) * 60)); expect(result.current.walkingDurationMinutes).toBe(18);
act(() => result.current.paddle.setSpeedKmh(6)); act(() => result.current.paddle.setSpeedKmh(6));
act(() => result.current.paddle.setBoatType('other')); act(() => result.current.paddle.setBoatType('other'));
expect(result.current.paddle.speedKmh).toBe(6); expect(result.current.paddle.speedKmh).toBe(6);
expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 6) * 60)); expect(result.current.walkingDurationMinutes).toBe(12);
act(() => result.current.paddle.adoptRecommendedSpeed()); act(() => result.current.paddle.adoptRecommendedSpeed());
expect(result.current.paddle.recommendedSpeedKmh).toBeNull(); expect(result.current.paddle.recommendedSpeedKmh).toBeNull();
@@ -343,4 +384,137 @@ describe('useTourPlanner route tools', () => {
expect(old.result.current.name).toBe('Old'); expect(old.result.current.name).toBe('Old');
}); });
}); });
describe('shaping and water routing (P2A-03, P2A-05, PAD-11)', () => {
const settle = () =>
act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
it('sets a shaping point into a leg, makes it a stop and back, and never past the end', async () => {
const { result } = plannerWith([
[48, 11],
[48.02, 11.02],
]);
const [start, end] = result.current.waypoints;
let id: string | null = null;
act(() => {
id = result.current.insertShapingPoint(must(start).id, 48.01, 11.005);
});
expect(result.current.waypoints.map((point) => !!point.shaping)).toEqual([false, true, false]);
expect(result.current.waypoints[1]?.id).toBe(id);
act(() => {
expect(result.current.insertShapingPoint(must(end).id, 48.03, 11.03)).toBeNull();
expect(result.current.insertShapingPoint(must(start).id, Number.NaN, 11)).toBeNull();
});
act(() => result.current.setWaypointShaping(must(id), false));
expect(result.current.waypoints[1]).not.toHaveProperty('shaping');
act(() => result.current.setWaypointShaping(must(start).id, true));
expect(result.current.waypoints[0]).not.toHaveProperty('shaping');
// One undo takes back one change.
act(() => result.current.undo());
expect(result.current.waypoints[1]?.shaping).toBe(true);
await settle();
expect(routeWalkingTour).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ shaping: true })]),
expect.any(AbortSignal),
2
);
});
it('plans a leg afresh: a paddle leg is forgotten and asked again, a fixed leg is left alone', async () => {
const { result } = plannerWith([
[48, 11],
[48.01, 11.02],
]);
act(() => result.current.setTourType('kayak'));
await settle();
expect(paddlePlan).toHaveBeenCalledTimes(1);
const [start, end] = result.current.waypoints;
act(() => result.current.recomputeLeg(must(start).id));
expect(paddleForget).toHaveBeenCalledWith(31, start, end, { boat: 'kayak', lakeStyle: 'direct' });
await settle();
expect(paddlePlan).toHaveBeenCalledTimes(2);
act(() => result.current.recomputeLeg(must(end).id));
expect(paddleForget).toHaveBeenCalledTimes(1);
});
it('sends every leg drawn by hand back to the water router in one step', async () => {
const { result } = plannerWith([
[48, 11],
[48.01, 11.02],
[48.02, 11],
]);
act(() => result.current.setTourType('kayak'));
for (const point of result.current.waypoints.slice(0, 2))
act(() => result.current.toggleWaypointFreeLeg(point.id));
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
act(() => result.current.routeDrawnLegsOnWater());
expect(result.current.waypoints.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
act(() => result.current.undo());
expect(result.current.waypoints[0]?.fixedLeg?.kind).toBe('free');
});
it('keeps a tour with an unplanned leg from being saved, and says it per leg', async () => {
paddlePlan.mockImplementation(async (_trip: number, legs: LegAsked[]) =>
legs.map(() => ({ status: 'failed', code: 'not_on_water', at: { lat: 48.01, lng: 11.02 } }))
);
const { result } = plannerWith([
[48, 11],
[48.01, 11.02],
]);
act(() => result.current.setName('Off the water'));
act(() => result.current.setTourType('kayak'));
act(() => result.current.paddle.setBoatType('kayak'));
act(() => result.current.paddle.setWaterContext('river'));
await settle();
expect(result.current.status).toBe('routing-failed');
expect(result.current.error).toBe('paddle-route');
expect(result.current.paddleRoute?.failures).toEqual([
{ index: 0, code: 'not_on_water', at: { lat: 48.01, lng: 11.02 } },
]);
expect(result.current.routeCurrent).toBe(true);
expect(result.current.canSave).toBe(false);
});
it('sets the portage and the lock the router found as points of their own, and they go when their leg changes', async () => {
paddlePlan.mockImplementation(async (_trip: number, legs: LegAsked[]) =>
legs.map(({ from, to }) => {
const mid = (a: number, b: number, t: number) => a + (b - a) * t;
const at = (t: number): [number, number] => [mid(from.lat, to.lat, t), mid(from.lng, to.lng, t)];
const geometry = [at(0), at(0.3), at(0.4), at(1)];
const segments = [
{ kind: 'water', from: 0, to: 1 },
{ kind: 'portage', from: 1, to: 2, source: 'tagged' },
{ kind: 'water', from: 2, to: 3 },
].map((segment) => ({ name: null, source: null, osm_ref: null, grade: null, ...segment }));
return { status: 'ok', geometry, segments, distance_m: 3000, snapped_m: { from: 0, to: 0 } };
})
);
const { result } = plannerWith([
[48, 11],
[48.1, 11],
]);
act(() => result.current.setTourType('kayak'));
await settle();
await settle();
expect(result.current.waypoints).toHaveLength(4);
expect(result.current.waypoints[1]).toMatchObject({ fixedLeg: { kind: 'portage' } });
expect(result.current.waypoints.filter((point) => point.auto)).toHaveLength(2);
// Found by the router, not done by the paddler: no step of the history of its own.
act(() => result.current.undo());
expect(result.current.tourType).toBe('hike');
act(() => result.current.redo());
await settle();
await settle();
const end = result.current.waypoints[result.current.waypoints.length - 1];
act(() => {
void result.current.setWaypointPosition(end.id, 48.2, 11);
});
await settle();
expect(result.current.waypoints.filter((point) => point.auto).every((point) => point.auto?.to.lat === 48.2)).toBe(
true
);
});
});
}); });
@@ -1596,7 +1596,7 @@ describe('useTourPlanner', () => {
expect(result.current.mode).toEqual({ type: 'new-draft' }) expect(result.current.mode).toEqual({ type: 'new-draft' })
expect(JSON.parse(localStorage.getItem('tour-draft-23') || 'null')).toMatchObject({ expect(JSON.parse(localStorage.getItem('tour-draft-23') || 'null')).toMatchObject({
version: 8, version: 9,
tourType: 'hike', tourType: 'hike',
name: '', name: '',
waypoints: [], waypoints: [],
@@ -1,29 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleSpeedKmhSchema, tourWebsiteSchema, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty, type PaddleLock } from '@trek/shared' import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleSpeedKmhSchema, tourWebsiteSchema, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
import { tourRepo } from '../../../repo/tourRepo' import { tourRepo } from '../../../repo/tourRepo'
import { randomId } from '../../../utils/randomId' import { randomId } from '../../../utils/randomId'
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry' import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint, type TourRoutePart } from './tourRouting' import { enrichTourElevations, routeWalkingTour, type TourElevationPoint, type TourRoutePart } from './tourRouting'
import { keepJoinedFixedLegs } from './tourFixedLegs' import { closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions'
import { alignFreeLegs, closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions' import { normalizePlannerWaypoints, plannerWaypointsOf, sameWaypointSnapshot, savedWaypointsOf, tourWaypointKey, type TourPlannerWaypoint } from './tourPlannerWaypoints'
import { useRouteShaping } from './useRouteShaping'
import { useTourMapView } from './useTourMapView' import { useTourMapView } from './useTourMapView'
import { validDraft, type DraftPayload } from './tourPlannerDraft' import { validDraft, type DraftPayload } from './tourPlannerDraft'
import { paddleEstimateOf } from './paddlePlanOf' import { paddleEstimateOf } from './paddlePlanOf'
import { adoptChangedPoints, planPaddleStep, type PaddleTourRoute } from './paddleRouting'
import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields' import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
import { hikingTimeMinutes } from '../hikingTime' import { hikingTimeMinutes } from '../hikingTime'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions' import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
export interface TourPlannerWaypoint {
id: string
lat: number
lng: number
role: TourWaypointRole
/** The leg to the next waypoint as a fixed line (a via ferrata on the tour), not routed. */
fixedLeg?: TourFixedLeg | null
/** PAD-07: a lock at this point of a paddle tour, with the times expected there. */
lock?: PaddleLock | null
}
/** What a tour planned from an import starts with. */ /** What a tour planned from an import starts with. */
export interface DerivedTourDraft { export interface DerivedTourDraft {
name: string name: string
@@ -69,29 +61,6 @@ function hasCompleteElevation(points: TourElevationPoint[] | null): points is [n
return !!points && points.every(point => point.length === 3 && Number.isFinite(point[2])) return !!points && points.every(point => point.length === 3 && Number.isFinite(point[2]))
} }
/** Roles from the order; free legs follow their points, and a paddle tour draws every leg by hand. */
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], allFree = false): TourPlannerWaypoint[] {
return keepJoinedFixedLegs(alignFreeLegs(points, allFree)).map((point, index) => ({
...point,
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
}))
}
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
return points.map(point => `${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${fixedLegKey(point.fixedLeg)}`).join('|')
}
function fixedLegKey(leg: TourFixedLeg | null | undefined): string {
if (!leg) return ''
return leg.kind === 'ferrata' ? `:${leg.ref}` : `:${leg.kind}`
}
/** The line, and what a paddler planned along it that draws no line: a portage's time, a lock. */
function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
const extras = (points: TourPlannerWaypoint[]) => JSON.stringify(points.map(point => [point.fixedLeg?.kind === 'portage' ? point.fixedLeg.extra_minutes ?? null : null, point.lock ?? null]))
return tourWaypointKey(a) === tourWaypointKey(b) && extras(a) === extras(b)
}
/** Where a new tour was planned from; an edit leaves it as it was saved. */ /** Where a new tour was planned from; an edit leaves it as it was saved. */
function derivedFromField(placeId: number | null, derivedFrom: number | null): { derived_from_place_id?: number } { function derivedFromField(placeId: number | null, derivedFrom: number | null): { derived_from_place_id?: number } {
return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {} return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {}
@@ -157,6 +126,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const [status, setStatus] = useState<TourPlannerStatus>(restored?.waypoints.length ? 'dirty' : 'empty') const [status, setStatus] = useState<TourPlannerStatus>(restored?.waypoints.length ? 'dirty' : 'empty')
const [route, setRoute] = useState<[number, number][] | null>(null) const [route, setRoute] = useState<[number, number][] | null>(null)
const [routeParts, setRouteParts] = useState<TourRoutePart[] | null>(null) const [routeParts, setRouteParts] = useState<TourRoutePart[] | null>(null)
const [paddleRoute, setPaddleRoute] = useState<PaddleTourRoute | null>(null)
const [enrichedGeometry, setEnrichedGeometry] = useState<TourElevationPoint[] | null>(null) const [enrichedGeometry, setEnrichedGeometry] = useState<TourElevationPoint[] | null>(null)
const [routeForKey, setRouteForKey] = useState<string | null>(null) const [routeForKey, setRouteForKey] = useState<string | null>(null)
const [distanceMeters, setDistanceMeters] = useState<number | null>(null) const [distanceMeters, setDistanceMeters] = useState<number | null>(null)
@@ -241,7 +211,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => { const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => {
if (!editPermissionRef.current) return if (!editPermissionRef.current) return
const current = waypointsRef.current const current = waypointsRef.current
const next = normalizePlannerWaypoints(typeof nextValue === 'function' ? nextValue(current) : nextValue, tourTypeRef.current === 'kayak') const changed = typeof nextValue === 'function' ? nextValue(current) : nextValue
const paddle = tourTypeRef.current === 'kayak'
// A point the router set that the paddler moves or edits is theirs from then on.
const next = normalizePlannerWaypoints(paddle ? adoptChangedPoints(current, changed) : changed, paddle)
if (sameWaypointSnapshot(current, next)) return if (sameWaypointSnapshot(current, next)) return
invalidateDetailRequest() invalidateDetailRequest()
interruptRouting() interruptRouting()
@@ -256,6 +229,19 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setError(null) setError(null)
}, [invalidateDetailRequest, interruptRouting, snapshot]) }, [invalidateDetailRequest, interruptRouting, snapshot])
// Detail laid in after the step that asked for it (a portage's way on land), with no step of its own.
const amendWaypoints = useCallback((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
if (!editPermissionRef.current || saveInFlightRef.current) return
const changed = change(waypointsRef.current)
if (changed === waypointsRef.current) return
interruptRouting()
draftRevisionRef.current += 1
waypointsRef.current = normalizePlannerWaypoints(changed, tourTypeRef.current === 'kayak')
setWaypoints(waypointsRef.current)
setHasUnsavedChanges(true)
setStatus('dirty')
}, [interruptRouting])
const setMaxHikingDifficulty = useCallback((value: TourMaxHikingDifficulty) => { const setMaxHikingDifficulty = useCallback((value: TourMaxHikingDifficulty) => {
if (!editPermissionRef.current) return if (!editPermissionRef.current) return
if (value === difficultyRef.current) return if (value === difficultyRef.current) return
@@ -333,9 +319,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
commitWaypoints(change) commitWaypoints(change)
}, [commitWaypoints]) }, [commitWaypoints])
/** P2A-04: the leg after this waypoint drawn by hand, or routed again. */ /** P2A-04: the leg after this waypoint drawn by hand, or routed again (a paddle tour's on the water). */
const toggleWaypointFreeLeg = useCallback((id: string) => { const toggleWaypointFreeLeg = useCallback((id: string) => {
if (!editPermissionRef.current || saveInFlightRef.current || tourTypeRef.current === 'kayak') return if (!editPermissionRef.current || saveInFlightRef.current) return
commitWaypoints(current => toggleFreeLeg(current, id)) commitWaypoints(current => toggleFreeLeg(current, id))
}, [commitWaypoints]) }, [commitWaypoints])
@@ -449,6 +435,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited) const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited)
const loadPaddle = paddle.load const loadPaddle = paddle.load
const paddleBoat = paddle.boatType
const paddleLakeStyle = paddle.lakeStyle
/** /**
* PAD-01: hike or paddle tour, as one step of the history with the route, * PAD-01: hike or paddle tour, as one step of the history with the route,
@@ -487,8 +475,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const paddleIncomplete = isPaddle && (paddle.boatType === null || paddle.waterContext === null) const paddleIncomplete = isPaddle && (paddle.boatType === null || paddle.waterContext === null)
// A paddle tour is timed on the water at its speed; a hike by the signposts' rule once the heights are in. // A paddle tour is timed on the water at its speed; a hike by the signposts' rule once the heights are in.
const { effectiveSpeedKmh, portageSpeedKmh } = paddle const { effectiveSpeedKmh, portageSpeedKmh } = paddle
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid) : null, // The routed length of each leg while the route is the current points' (PAD-11); straight lines before that.
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, waypoints]) const routedLegLengths = paddleRoute && routeForKey === tourWaypointKey(waypoints) ? paddleRoute.legLengths : undefined
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid, routedLegLengths) : null,
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths, waypoints])
const walkingDurationMinutes = paddleEstimate const walkingDurationMinutes = paddleEstimate
? paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes ? paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes
: routeAnalysis?.gain != null && routeAnalysis.loss != null : routeAnalysis?.gain != null && routeAnalysis.loss != null
@@ -501,11 +491,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
&& (!Number.isInteger(breakAdditionalMinutes) || breakAdditionalMinutes < 0 || breakAdditionalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES) && (!Number.isInteger(breakAdditionalMinutes) || breakAdditionalMinutes < 0 || breakAdditionalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES)
const plannedTotalMinutes = plannedDurationMinutes ?? automaticPlannedTotalMinutes const plannedTotalMinutes = plannedDurationMinutes ?? automaticPlannedTotalMinutes
// What the type adds to a save: a hike its routing limit, a paddle tour its boat, water and speed. // What the type adds to a save: a hike its routing limit, a paddle tour its boat, water and speed.
const { boatType, waterContext, speedKmh } = paddle const { boatType, waterContext, speedKmh, lakeStyle } = paddle
const typeFields = useMemo(() => tourType === 'kayak' const typeFields = useMemo(() => tourType === 'kayak'
? { tour_type: tourType, max_hiking_difficulty: null, boat_type: boatType, water_context: waterContext, paddle_speed_kmh: speedKmh, portage_speed_kmh: portageSpeedKmh } ? { tour_type: tourType, max_hiking_difficulty: null, boat_type: boatType, water_context: waterContext, paddle_speed_kmh: speedKmh, portage_speed_kmh: portageSpeedKmh, paddle_lake_style: lakeStyle }
: { tour_type: tourType, max_hiking_difficulty: maxHikingDifficulty, boat_type: null, water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null }, : { tour_type: tourType, max_hiking_difficulty: maxHikingDifficulty, boat_type: null, water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null, paddle_lake_style: null },
[boatType, maxHikingDifficulty, portageSpeedKmh, speedKmh, tourType, waterContext]) [boatType, lakeStyle, maxHikingDifficulty, portageSpeedKmh, speedKmh, tourType, waterContext])
// The router's own time stands in for a hike without heights; a paddle tour has only its speed. // The router's own time stands in for a hike without heights; a paddle tour has only its speed.
const savedDurationSeconds = walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60 const savedDurationSeconds = walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60
const plannedTotalInvalid = plannedDurationMinutes === null const plannedTotalInvalid = plannedDurationMinutes === null
@@ -537,6 +527,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setStatus('empty') setStatus('empty')
setRoute(null) setRoute(null)
setRouteParts(null) setRouteParts(null)
setPaddleRoute(null)
setEnrichedGeometry(null) setEnrichedGeometry(null)
setRouteForKey(null) setRouteForKey(null)
setDistanceMeters(null) setDistanceMeters(null)
@@ -676,14 +667,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
interruptRouting() interruptRouting()
const openedType: PlannableTourType = result.tour.tour_type === 'kayak' ? 'kayak' : 'hike' const openedType: PlannableTourType = result.tour.tour_type === 'kayak' ? 'kayak' : 'hike'
const nextWaypoints = normalizePlannerWaypoints(result.waypoints.map(point => ({ const nextWaypoints = normalizePlannerWaypoints(plannerWaypointsOf(result.waypoints, randomId), openedType === 'kayak')
id: randomId(),
lat: point.lat,
lng: point.lng,
role: point.role,
...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}),
...(point.lock ? { lock: point.lock } : {}),
})), openedType === 'kayak')
localStorage.removeItem(storageKey) localStorage.removeItem(storageKey)
contextGenerationRef.current += 1 contextGenerationRef.current += 1
draftRevisionRef.current += 1 draftRevisionRef.current += 1
@@ -704,6 +688,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setStatus('dirty') setStatus('dirty')
setRoute(null) setRoute(null)
setRouteParts(null) setRouteParts(null)
setPaddleRoute(null)
setEnrichedGeometry(null) setEnrichedGeometry(null)
setRouteForKey(null) setRouteForKey(null)
setDistanceMeters(null) setDistanceMeters(null)
@@ -736,6 +721,15 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setRetryToken(value => value + 1) setRetryToken(value => value + 1)
}, [interruptRouting]) }, [interruptRouting])
// P2A-03/P2A-05/PAD-11: shaping points, a leg planned afresh, drawn legs handed back to the router.
const shaping = useRouteShaping({
commit: commitWaypoints, waypointsRef, retry,
mayEdit: useCallback(() => editPermissionRef.current && !saveInFlightRef.current, []),
isPaddle: useCallback(() => tourTypeRef.current === 'kayak', []),
forgetLeg: useCallback((from: TourPlannerWaypoint, to: TourPlannerWaypoint) =>
paddleRouteRepo.forget(tripId, from, to, { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle }), [paddleBoat, paddleLakeStyle, tripId]),
})
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints]) const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints])
const readOnlyGpxAnalysis = useMemo( const readOnlyGpxAnalysis = useMemo(
() => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry), () => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry),
@@ -764,10 +758,40 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const controller = new AbortController() const controller = new AbortController()
abortRef.current?.abort() abortRef.current?.abort()
abortRef.current = controller abortRef.current = controller
const timer = window.setTimeout(async () => { const stale = () => controller.signal.aborted || generation !== generationRef.current
// Points the router set, changed by its own planning and not by the paddler: no step of the history.
const patch = (next: TourPlannerWaypoint[]) => {
waypointsRef.current = next
setWaypoints(next)
}
// PAD-11: a paddle tour is planned on the water by the server's router; its heights are no concern.
const routePaddle = async () => {
setStatus('routing')
const step = await planPaddleStep(waypoints, tripId, { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle }, controller.signal, randomId)
if (stale()) return
if ('points' in step) return patch(normalizePlannerWaypoints(step.points, true))
const planned = step.route
setRoute(planned.coordinates)
setRouteParts(planned.parts)
setPaddleRoute(planned)
setDistanceMeters(planned.distanceMeters)
setDurationSeconds(null)
setRouteForKey(waypointKey)
setEnrichedGeometry(planned.coordinates.map(([lat, lng]): [number, number, number] => [lat, lng, 0]))
if (planned.failures.length > 0) {
setStatus('routing-failed')
setError('paddle-route')
return
}
setStatus('ready')
setError(null)
}
const routeHike = async () => {
setStatus('routing') setStatus('routing')
const routed = await routeWalkingTour(waypoints, controller.signal, maxHikingDifficulty) const routed = await routeWalkingTour(waypoints, controller.signal, maxHikingDifficulty)
if (controller.signal.aborted || generation !== generationRef.current) return if (stale()) return
if (!routed) { if (!routed) {
setStatus('routing-failed') setStatus('routing-failed')
setError('routing') setError('routing')
@@ -776,6 +800,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setRoute(routed.coordinates) setRoute(routed.coordinates)
setRouteParts(routed.parts) setRouteParts(routed.parts)
setPaddleRoute(null)
setDistanceMeters(routed.distanceMeters) setDistanceMeters(routed.distanceMeters)
setDurationSeconds(routed.durationSeconds) setDurationSeconds(routed.durationSeconds)
setRouteForKey(waypointKey) setRouteForKey(waypointKey)
@@ -783,7 +808,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setStatus('enriching-elevation') setStatus('enriching-elevation')
const enriched = await enrichTourElevations(routed.coordinates, controller.signal) const enriched = await enrichTourElevations(routed.coordinates, controller.signal)
if (controller.signal.aborted || generation !== generationRef.current) return if (stale()) return
if (!enriched) { if (!enriched) {
setStatus('elevation-failed') setStatus('elevation-failed')
setError('elevation') setError('elevation')
@@ -797,24 +822,33 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
} }
setStatus('ready') setStatus('ready')
setError(null) setError(null)
}
const timer = window.setTimeout(() => {
const run = isPaddle ? routePaddle() : routeHike()
run.catch(() => {
if (stale()) return
setStatus('routing-failed')
setError('routing')
})
}, ROUTE_DEBOUNCE_MS) }, ROUTE_DEBOUNCE_MS)
return () => { return () => {
window.clearTimeout(timer) window.clearTimeout(timer)
controller.abort() controller.abort()
} }
}, [interruptRouting, maxHikingDifficulty, retryToken, waypointKey, waypoints]) }, [interruptRouting, isPaddle, maxHikingDifficulty, paddleBoat, paddleLakeStyle, retryToken, tripId, waypointKey, waypoints])
useEffect(() => { useEffect(() => {
if (!hasUnsavedChanges) return if (!hasUnsavedChanges) return
const draft: DraftPayload = { version: 8, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId, const draft: DraftPayload = { version: 9, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
derivedFromPlaceId, tourType, derivedFromPlaceId, tourType,
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh }, paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
} }
localStorage.setItem(storageKey, JSON.stringify(draft)) localStorage.setItem(storageKey, JSON.stringify(draft))
}, [breakAdditionalMinutes, }, [breakAdditionalMinutes,
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, storageKey, waypoints, website, derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, storageKey, waypoints, website,
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh]) tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle])
useEffect(() => { useEffect(() => {
if (!hasUnsavedChanges) return if (!hasUnsavedChanges) return
@@ -844,14 +878,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
website: parsedWebsiteOrNull(website), website: parsedWebsiteOrNull(website),
...typeFields, ...typeFields,
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]), route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
waypoints: waypoints.map((point, sequence) => ({ waypoints: savedWaypointsOf(waypoints),
lat: point.lat,
lng: point.lng,
role: point.role,
sequence,
...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}),
...(point.lock ? { lock: point.lock } : {}),
})),
duration_seconds: savedDurationSeconds, duration_seconds: savedDurationSeconds,
planned_duration_minutes: plannedDurationMinutes, planned_duration_minutes: plannedDurationMinutes,
break_additional_minutes: breakAdditionalMinutes, break_additional_minutes: breakAdditionalMinutes,
@@ -909,7 +936,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid, walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid,
waypoints, selectedWaypointId, setSelectedWaypointId, waypoints, selectedWaypointId, setSelectedWaypointId,
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint, addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint,
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, paddleEstimate, reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, amendWaypoints, paddleEstimate,
...shaping, paddleRoute, routeCurrent: routeForKey === waypointKey,
undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0, undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0,
status, error, retry, isSaving, status, error, retry, isSaving,
route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds, route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds,
@@ -360,6 +360,20 @@ describe('TourTerrainMap', () => {
expect(screen.queryByText('18 to 27 %')).toBeNull(); expect(screen.queryByText('18 to 27 %')).toBeNull();
}); });
it('FE-COMP-TERRAIN-012b: a paddle tour keeps its tour colour, without the grade switch or the grade mix', async () => {
await renderTerrain({ samples: TOUR, water: true });
loadStyle();
await act(async () => openTiles());
expect(screen.queryByRole('button', { name: 'Grade' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Grade mix' })).toBeNull();
const setData = gl.sources.get('tour-terrain-track')?.setData as ReturnType<typeof vi.fn> | undefined;
const drawCalls = setData?.mock.calls ?? [];
const drawn = drawCalls[drawCalls.length - 1]?.[0] as { features: { properties: { color: string } }[] } | undefined;
expect(drawn?.features.length).toBeGreaterThan(0);
expect(drawn?.features.every((feature) => feature.properties.color === '#0a84ff')).toBe(true);
});
it('FE-COMP-TERRAIN-013: swaps the style in place for another base layer and draws on it again', async () => { it('FE-COMP-TERRAIN-013: swaps the style in place for another base layer and draws on it again', async () => {
const { rerender } = await renderTerrain({ samples: TOUR }); const { rerender } = await renderTerrain({ samples: TOUR });
loadStyle(); loadStyle();
@@ -37,6 +37,8 @@ export interface TourTerrainMapProps {
rightWidth: number; rightWidth: number;
waypoints: TerrainWaypoints; waypoints: TerrainWaypoints;
framePoints: [number, number][]; framePoints: [number, number][];
/** A paddle tour: no grade colours, no grade legend and no switch for them, the water is flat. */
water?: boolean;
/** The planner map's via ferrata layer: its switch joins the layer pill, its lines the terrain. */ /** The planner map's via ferrata layer: its switch joins the layer pill, its lines the terrain. */
ferrataLayer?: FerrataMapLayer; ferrataLayer?: FerrataMapLayer;
/** What the tour's type offers: its base layers, and the overlays drawn on the terrain too. */ /** What the tour's type offers: its base layers, and the overlays drawn on the terrain too. */
@@ -78,7 +80,10 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } }); const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } });
const stats = useMemo(() => terrainStats(samples), [samples]); const stats = useMemo(() => terrainStats(samples), [samples]);
const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit(); const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit();
const legend = terrain.canReplay ? <GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} /> : null; const legend =
terrain.canReplay && !props.water ? (
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} />
) : null;
const legendAbove = fit === 'stacked'; const legendAbove = fit === 'stacked';
useEffect(() => { useEffect(() => {
@@ -120,7 +125,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3"> <div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
<span /> <span />
{terrain.canReplay ? ( {terrain.canReplay ? (
<TerrainDock terrain={terrain} /> <TerrainDock terrain={terrain} water={props.water} />
) : ( ) : (
<div <div
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted" className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
@@ -173,8 +178,14 @@ function Divider() {
return <span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />; return <span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />;
} }
/** One pill like the layer switcher: play, restart, overview · speed · relief · colour. */ /** One pill like the layer switcher: play, restart, overview · speed · relief · colour (not on water). */
function TerrainDock({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'> }) { function TerrainDock({
terrain,
water,
}: {
terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'>;
water?: boolean;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="pointer-events-auto flex items-center gap-0.5 rounded-full p-1" style={GLASS}> <div className="pointer-events-auto flex items-center gap-0.5 rounded-full p-1" style={GLASS}>
@@ -231,6 +242,16 @@ function TerrainDock({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerra
<span className="w-7 font-semibold tabular-nums">{terrain.exaggeration.toFixed(1)}×</span> <span className="w-7 font-semibold tabular-nums">{terrain.exaggeration.toFixed(1)}×</span>
</label> </label>
</Tooltip> </Tooltip>
{!water && <GradeSwitch terrain={terrain} />}
</div>
);
}
/** Colour the route by grade or in the tour colour. */
function GradeSwitch({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'> }) {
const { t } = useTranslation();
return (
<>
<Divider /> <Divider />
<button <button
type="button" type="button"
@@ -242,7 +263,7 @@ function TerrainDock({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerra
<Palette size={15} strokeWidth={2} /> <Palette size={15} strokeWidth={2} />
{t('tours.terrain.colorByGrade')} {t('tours.terrain.colorByGrade')}
</button> </button>
</div> </>
); );
} }
@@ -147,12 +147,12 @@ describe('camera and markers', () => {
}); });
it('numbers a control point and shows whether it can be dragged', () => { it('numbers a control point and shows whether it can be dragged', () => {
const pin = waypointPinElement(2, true, true); const pin = waypointPinElement({}, 2, true, true);
expect(pin.textContent).toBe('3'); expect(pin.textContent).toBe('3');
expect(pin.getAttribute('aria-label')).toBe('Waypoint 3'); expect(pin.getAttribute('aria-label')).toBe('Waypoint 3');
expect(pin.style.width).toBe('30px'); expect(pin.style.width).toBe('30px');
expect(pin.style.cursor).toBe('grab'); expect(pin.style.cursor).toBe('grab');
const fixed = waypointPinElement(0, false, false); const fixed = waypointPinElement({}, 0, false, false);
expect(fixed.style.width).toBe('26px'); expect(fixed.style.width).toBe('26px');
expect(fixed.style.cursor).toBe('pointer'); expect(fixed.style.cursor).toBe('pointer');
}); });
@@ -3,6 +3,7 @@ import { OPENTOPOMAP_TILE_URL, SATELLITE_TILE_URL } from '../../../constants/map
import type { DistanceIndexedProfileSample } from '../../../utils/routeGeometry'; import type { DistanceIndexedProfileSample } from '../../../utils/routeGeometry';
import { OPENFREEMAP_DEFAULT_STYLE } from '../../Map/glProviders'; import { OPENFREEMAP_DEFAULT_STYLE } from '../../Map/glProviders';
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'; import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
import { plannerPinShape, type PlannerPinPoint } from '../../Map/plannerPinShape';
import type { TileTemplate } from './terrainPrefetch'; import type { TileTemplate } from './terrainPrefetch';
import { gradeBand, gradeSegments } from './tourTerrainModel'; import { gradeBand, gradeSegments } from './tourTerrainModel';
@@ -198,11 +199,16 @@ export function riderElement(trackColor: string): HTMLElement {
} }
/** A numbered control point, the same pin the flat planner map draws. */ /** A numbered control point, the same pin the flat planner map draws. */
export function waypointPinElement(index: number, selected: boolean, editable: boolean): HTMLButtonElement { export function waypointPinElement(
const size = selected ? 30 : 26; point: PlannerPinPoint,
index: number,
selected: boolean,
editable: boolean
): HTMLButtonElement {
const { size, text } = plannerPinShape(point, index, selected);
const el = document.createElement('button'); const el = document.createElement('button');
el.type = 'button'; el.type = 'button';
el.textContent = String(index + 1); el.textContent = text;
el.setAttribute('aria-label', `Waypoint ${index + 1}`); el.setAttribute('aria-label', `Waypoint ${index + 1}`);
el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:${PIN_BLUE};color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:${editable ? 'grab' : 'pointer'};padding:0`; el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:${PIN_BLUE};color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:${editable ? 'grab' : 'pointer'};padding:0`;
return el; return el;
@@ -6,7 +6,15 @@ import type { TerrainGl } from './useTerrainMap';
export interface TerrainWaypoints { export interface TerrainWaypoints {
/** `name` and `detail` (its role) show on hover. */ /** `name` and `detail` (its role) show on hover. */
points: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>; points: Array<{
id: string;
lat: number;
lng: number;
name?: string;
detail?: string;
label?: string;
shaping?: boolean;
}>;
selectedId: string | null; selectedId: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
/** Absent while the tour cannot be edited; the pins then stay put. */ /** Absent while the tour cannot be edited; the pins then stay put. */
@@ -38,7 +46,7 @@ export function useTerrainPins(
gl.map.on('movestart', hideOnMove); gl.map.on('movestart', hideOnMove);
const unbind: (() => void)[] = [() => gl.map.off('movestart', hideOnMove)]; const unbind: (() => void)[] = [() => gl.map.off('movestart', hideOnMove)];
pinsRef.current = points.map((point, index) => { pinsRef.current = points.map((point, index) => {
const el = waypointPinElement(index, point.id === selectedId, editable); const el = waypointPinElement(point, index, point.id === selectedId, editable);
const name = point.name ?? String(index + 1); const name = point.name ?? String(index + 1);
el.setAttribute('aria-label', name); el.setAttribute('aria-label', name);
unbind.push(bindPinHoverLabel(el, hoverLabel, gl.map.getContainer(), { name, detail: point.detail ?? null })); unbind.push(bindPinHoverLabel(el, hoverLabel, gl.map.getContainer(), { name, detail: point.detail ?? null }));
@@ -30,13 +30,17 @@ interface Options {
tourLayers?: TourLayerOffer; tourLayers?: TourLayerOffer;
/** The visible area, for the via ferrata layer to ask for. */ /** The visible area, for the via ferrata layer to ask for. */
onViewportChange?: (bbox: FerrataBbox) => void; onViewportChange?: (bbox: FerrataBbox) => void;
/** A paddle tour: the route is never coloured by grade. */
water?: boolean;
} }
/** The planner map's 3D mode: map, pins, corridor preload and the flyover sharing the rail's focus. */ /** The planner map's 3D mode: map, pins, corridor preload and the flyover sharing the rail's focus. */
export function useTourTerrain(options: Options) { export function useTourTerrain(options: Options) {
const { samples, routes, trackColor, baseLayer, focus, onFocusChange, panels, waypoints, framePoints } = options; const { samples, routes, trackColor, baseLayer, focus, onFocusChange, panels, waypoints, framePoints } = options;
const [exaggeration, setExaggeration] = useState(1.5); const [exaggeration, setExaggeration] = useState(1.5);
const [colorMode, setColorMode] = useState<TrackColorMode>('grade'); const [chosenColorMode, setColorMode] = useState<TrackColorMode>('grade');
// Water is flat: a paddle tour keeps its tour colour, whatever was picked for a hike.
const colorMode: TrackColorMode = options.water ? 'tour' : chosenColorMode;
const data = useMemo( const data = useMemo(
() => trackData(samples, routes, colorMode, trackColor), () => trackData(samples, routes, colorMode, trackColor),
[samples, routes, colorMode, trackColor] [samples, routes, colorMode, trackColor]
@@ -0,0 +1,37 @@
import { BookOpen, Link2 } from 'lucide-react';
/**
* The trip's Journal tab: a book with a link on its corner, because the journal
* is not the trip's own but one from the Journey addon tied to it. Takes the
* props the tab bars hand a lucide icon.
*
* The book is masked out under the link rather than the link given a backing:
* a backing has to match whatever the icon sits on (a card, the accent of a
* picked tab), and the moment it does not, the link vanishes into it.
*/
export function LinkedJournalIcon({
size = 16,
strokeWidth = 2,
className,
}: {
size?: number;
strokeWidth?: number;
className?: string;
}) {
const badge = Math.round(size * 0.6);
// The hole: a circle round the link's centre, a little wider than the link.
const hole = `radial-gradient(circle at calc(100% + 2px) calc(100% + 1px), transparent ${badge * 0.62}px, currentColor ${badge * 0.62 + 0.5}px)`;
return (
// dir="ltr": the hole is cut on the right, so the link has to stay there in Arabic too.
<span
dir="ltr"
className={`relative inline-flex flex-none ${className ?? ''}`}
style={{ width: size, height: size }}
>
<span className="inline-flex" style={{ maskImage: hole, WebkitMaskImage: hole }}>
<BookOpen size={size} strokeWidth={strokeWidth} />
</span>
<Link2 size={badge} strokeWidth={strokeWidth + 0.6} className="absolute -bottom-[3px] -end-[4px]" />
</span>
);
}
@@ -0,0 +1,150 @@
// FE-TJL-SECTION-001 to FE-TJL-SECTION-007
/**
* The Journal part of the trip's Settings, rendered: what someone sees with no
* journal linked, with one linked, while picking, and the warning that names
* the people a save would let in for the first time.
*/
import userEvent from '@testing-library/user-event';
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { seedStore } from '../../../tests/helpers/store';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
import { LinkedJournalIcon } from './LinkedJournalIcon';
import { TripJournalLinkSection } from './TripJournalLinkSection';
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const TRIP = 4;
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
const LINKED: TripJournalLinkResponse = {
link: {
journey_id: 9,
title: 'Japan',
subtitle: null,
cover_image: 'covers/j.jpg',
owner_id: 1,
linked_by: 1,
created_at: 1,
my_role: 'owner',
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
},
can_link: true,
can_unlink: true,
can_edit_members: true,
};
const CANDIDATES: TripJournalCandidate[] = [
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: true, access: 'editor' },
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
];
let answer: TripJournalLinkResponse = EMPTY;
let deletes = 0;
beforeEach(() => {
answer = EMPTY;
deletes = 0;
vi.clearAllMocks();
useTripJournalLinkStore.setState({ byTrip: {} });
seedStore(useAuthStore, { user: buildUser({ id: 1 }) });
useAddonStore.setState({
loaded: true,
addons: [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }],
});
server.use(
http.get('/api/trips/:tripId/journal', () => HttpResponse.json(answer)),
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
http.get('/api/journeys', () => HttpResponse.json({ journeys: [{ id: 9, user_id: 1, title: 'Japan' }] })),
http.delete('/api/trips/:tripId/journal', () => {
deletes += 1;
return HttpResponse.json(EMPTY);
})
);
});
describe('TripJournalLinkSection', () => {
it('FE-TJL-SECTION-001: renders nothing with the Journey addon off', async () => {
useAddonStore.setState({ addons: [] });
const { container } = render(<TripJournalLinkSection tripId={TRIP} />);
await new Promise((r) => setTimeout(r, 30));
expect(container).toBeEmptyDOMElement();
});
it('FE-TJL-SECTION-002: with no journal linked it offers to link one, in its own card', async () => {
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
expect(screen.getByText('Journal')).toBeInTheDocument();
});
it('FE-TJL-SECTION-003: picking a journal lists the trip and warns about who gets in for the first time', async () => {
const user = userEvent.setup();
render(<TripJournalLinkSection tripId={TRIP} framed={false} />);
await user.click(await screen.findByText('Link a journal'));
await user.click(await screen.findByText('Choose a journal'));
// The select opens its list in a portal, one button per journal.
await user.click(await screen.findByRole('button', { name: 'Japan' }));
expect(await screen.findByText('tom')).toBeInTheDocument();
expect(screen.getByText('Has access')).toBeInTheDocument();
expect(screen.getByRole('note')).toHaveTextContent('tom will get access to this journal for the first time');
await user.click(screen.getAllByRole('radio', { name: 'No access' })[1]);
expect(screen.queryByRole('note')).not.toBeInTheDocument();
await user.click(screen.getByText('Everyone can read'));
expect(screen.getByRole('note')).toBeInTheDocument();
await user.click(screen.getByText('Nobody'));
expect(screen.queryByRole('note')).not.toBeInTheDocument();
await user.click(screen.getByText('Cancel'));
expect(screen.getByText('Link a journal')).toBeInTheDocument();
});
it('FE-TJL-SECTION-004: a linked journal shows its cover, my role and who it is open to', async () => {
answer = LINKED;
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Japan')).toBeInTheDocument();
expect(screen.getByText('Your journal')).toBeInTheDocument();
expect(screen.getByText('Linked')).toBeInTheDocument();
expect(screen.getByText('lena')).toBeInTheDocument();
expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).not.toBeNull();
});
it('FE-TJL-SECTION-005: unlinking asks first, then takes it off', async () => {
const user = userEvent.setup();
answer = LINKED;
render(<TripJournalLinkSection tripId={TRIP} />);
await user.click(await screen.findByRole('button', { name: 'Unlink' }));
expect(screen.getByText('Unlink this journal?')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Unlink' });
await user.click(buttons[buttons.length - 1]);
await waitFor(() => expect(deletes).toBe(1));
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
});
it('FE-TJL-SECTION-006: editing access lists the trip with each one as the link holds them', async () => {
const user = userEvent.setup();
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, members: [] } };
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Nobody else from this trip has access yet.')).toBeInTheDocument();
await user.click(screen.getByText('Edit access'));
expect(await screen.findByText('tom')).toBeInTheDocument();
expect(screen.getByText('Owner')).toBeInTheDocument();
expect(
screen.getAllByRole('radio', { name: 'No access' }).every((r) => r.getAttribute('aria-checked') === 'true')
).toBe(true);
});
it('FE-TJL-SECTION-007: the linked-journal icon draws a book with the link on its corner', () => {
const { container } = render(<LinkedJournalIcon size={20} className="x" />);
const root = container.firstElementChild as HTMLElement;
expect(root.getAttribute('dir')).toBe('ltr');
expect(root.style.width).toBe('20px');
expect(container.querySelectorAll('svg')).toHaveLength(2);
});
});
@@ -0,0 +1,324 @@
import type { TripJournalCandidate, TripJournalLinkRole } from '@trek/shared';
import { AlertTriangle, BookOpen, Link2, Unlink, Users } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import ConfirmDialog from '../shared/ConfirmDialog';
import CustomSelect from '../shared/CustomSelect';
import { DialogButton, fs } from '../shared/DialogShell';
import { AddRowButton } from '../shared/dialogParts';
import { LinkedJournalIcon } from './LinkedJournalIcon';
import { TripMemberAvatar } from './TripMemberAvatar';
import { LinkCard } from './TripShareDialog';
import { useTripJournalLink, type JournalMemberChoice } from './useTripJournalLink';
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5';
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5';
const SEGMENT = 'rounded-full px-2.5 py-1 font-semibold';
const ROLES: { value: TripJournalLinkRole | null; labelKey: string }[] = [
{ value: null, labelKey: 'trip.journal.roleNone' },
{ value: 'viewer', labelKey: 'trip.journal.roleViewer' },
{ value: 'editor', labelKey: 'trip.journal.roleEditor' },
];
/**
* The Journal part of the trip's Settings, for the desktop dialog and the phone
* sheet alike: the journal the trip shows, and who of the trip it is open to.
* Hidden with the Journey addon off, and for someone who can neither link one
* nor see one that is linked.
*/
export function TripJournalLinkSection({ tripId, framed = true }: { tripId: number; framed?: boolean }) {
const { t } = useTranslation();
const j = useTripJournalLink(tripId);
const [askUnlink, setAskUnlink] = useState(false);
if (!j.visible) return null;
// The desktop dialog names the area above it already; the phone sheet gets the card with its own title.
const Frame = framed ? JournalCard : Bare;
return (
<>
<Frame>
{j.mode === 'view' && j.link && <LinkedJournal j={j} />}
{j.mode === 'view' && !j.link && j.canLink && (
<AddRowButton onClick={j.startPick}>{t('trip.journal.link')}</AddRowButton>
)}
{j.mode === 'view' && j.link && (
<div className="flex flex-wrap gap-2">
{j.canEditMembers && (
<DialogButton onClick={j.editMembers} icon={<Users size={14} strokeWidth={2.2} />}>
{t('trip.journal.editMembers')}
</DialogButton>
)}
{j.canLink && (
<DialogButton onClick={j.startPick} icon={<BookOpen size={14} strokeWidth={2.2} />}>
{t('trip.journal.change')}
</DialogButton>
)}
{j.canUnlink && (
<button
type="button"
onClick={() => setAskUnlink(true)}
disabled={j.busy}
className="inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:opacity-50"
style={fs(12.5, 'body')}
>
<Unlink size={13} strokeWidth={2.2} />
{t('trip.journal.unlink')}
</button>
)}
</div>
)}
{j.mode === 'pick' && <JournalPicker j={j} />}
{(j.mode === 'members' || (j.mode === 'pick' && j.pickedId != null)) && <MemberChooser j={j} />}
{j.mode !== 'view' && (
<div className="flex justify-end gap-2">
<DialogButton onClick={j.cancel} disabled={j.busy}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
onClick={() => void j.save()}
disabled={!j.canSave}
icon={<Link2 size={14} strokeWidth={2.2} />}
>
{j.mode === 'pick' ? t('trip.journal.linkSubmit') : t('common.save')}
</DialogButton>
</div>
)}
</Frame>
<ConfirmDialog
isOpen={askUnlink}
onClose={() => setAskUnlink(false)}
onConfirm={() => {
setAskUnlink(false);
void j.unlink();
}}
title={t('trip.journal.unlinkTitle')}
message={t('trip.journal.unlinkMessage')}
confirmLabel={t('trip.journal.unlink')}
/>
</>
);
}
function JournalCard({ children }: { children: ReactNode }) {
const { t } = useTranslation();
return (
<LinkCard
icon={<LinkedJournalIcon size={15} strokeWidth={2.2} />}
title={t('trip.journal.title')}
hint={t('trip.journal.hint')}
>
{children}
</LinkCard>
);
}
function Bare({ children }: { children: ReactNode }) {
return <div className="flex flex-col gap-4">{children}</div>;
}
type JournalLink = ReturnType<typeof useTripJournalLink>;
/** The linked journal: cover, title, what the reader may do in it, and who it is open to. */
function LinkedJournal({ j }: { j: JournalLink }) {
const { t } = useTranslation();
const link = j.link;
if (!link) return null;
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card p-2.5">
<span className="grid h-11 w-11 flex-none place-items-center overflow-hidden rounded-[10px] bg-surface-tertiary text-content-muted">
{link.cover_image ? (
<img src={`/uploads/${link.cover_image}`} alt="" className="h-full w-full object-cover" />
) : (
<BookOpen size={18} strokeWidth={2} />
)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13.5, 'body')}>
{link.title}
</div>
{link.my_role && (
<div className="truncate text-content-faint" style={fs(11.5)}>
{t(`trip.journal.yourRole.${link.my_role}`)}
</div>
)}
</div>
<span
className="inline-flex flex-none items-center gap-1 rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success"
style={fs(10.5)}
>
<Link2 size={10} strokeWidth={2.6} />
{t('trip.journal.linkedBadge')}
</span>
</div>
{link.members && link.members.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{link.members.map((m) => (
<span
key={m.user_id}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card py-[3px] pe-2.5 ps-[3px] shadow-sm ring-1 ring-edge-faint"
style={fs(11.5)}
>
<TripMemberAvatar username={m.username} avatarUrl={m.avatar_url} size={20} />
<span className="font-medium text-content">{m.username}</span>
<span className="text-content-faint">
{t(m.role === 'editor' ? 'trip.journal.roleEditor' : 'trip.journal.roleViewer')}
</span>
</span>
))}
</div>
)}
{link.members && link.members.length === 0 && (
<p className="m-0 text-content-faint" style={fs(11.5)}>
{t('trip.journal.onlyYou')}
</p>
)}
</div>
);
}
/** The journals of one's own to choose from; one only contributed to is not one's to give away. */
function JournalPicker({ j }: { j: JournalLink }) {
const { t } = useTranslation();
if (j.journals == null) return <div className="h-10 animate-pulse rounded-[10px] bg-surface-tertiary" />;
if (j.journals.length === 0)
return (
<p className="m-0 text-content-faint" style={fs(12)}>
{t('trip.journal.noJournals')}
</p>
);
return (
<CustomSelect
value={j.pickedId == null ? '' : String(j.pickedId)}
onChange={(value) => j.pickJournal(value === '' ? null : Number(value))}
placeholder={t('trip.journal.pickPlaceholder')}
options={[
{ value: '', label: t('trip.journal.pickPlaceholder') },
...j.journals.map((journal) => ({ value: String(journal.id), label: journal.title })),
]}
searchable
size="sm"
/>
);
}
/**
* Everyone of the trip, each with no access, read or edit. Before saving, the
* people the link would let in for the first time are named: from then on they
* see the journal in their own Journey list as well.
*/
function MemberChooser({ j }: { j: JournalLink }) {
const { t } = useTranslation();
if (j.candidates == null) return <div className="h-24 animate-pulse rounded-[14px] bg-surface-tertiary" />;
if (j.candidates.length === 0)
return (
<p className="m-0 text-content-faint" style={fs(12)}>
{t('trip.journal.noMembers')}
</p>
);
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-content-secondary" style={fs(12, 'body')}>
{t('trip.journal.members')}
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={() => j.setAll('viewer')}
className="text-content-muted hover:text-content"
style={fs(11.5)}
>
{t('trip.journal.allRead')}
</button>
<span className="text-content-faint" aria-hidden>
/
</span>
<button
type="button"
onClick={() => j.setAll(null)}
className="text-content-muted hover:text-content"
style={fs(11.5)}
>
{t('trip.journal.allNone')}
</button>
</div>
</div>
<div className={ROWS}>
{j.candidates.map((c) => (
<MemberRow key={c.user_id} c={c} choice={j.choice} onRole={j.setRole} showAccess={j.mode === 'pick'} />
))}
</div>
{j.newlyGranted.length > 0 && (
<div role="note" className="flex items-start gap-2 rounded-[12px] bg-warning-soft px-3 py-2.5 text-warning">
<AlertTriangle size={15} strokeWidth={2.2} className="mt-px flex-none" />
<p className="m-0 leading-normal" style={fs(12)}>
{t('trip.journal.warnNew', {
count: j.newlyGranted.length,
names: j.newlyGranted.map((c) => c.username).join(', '),
})}
</p>
</div>
)}
</div>
);
}
function MemberRow({
c,
choice,
onRole,
showAccess,
}: {
c: TripJournalCandidate;
choice: JournalMemberChoice;
onRole: (userId: number, role: TripJournalLinkRole | null) => void;
showAccess: boolean;
}) {
const { t } = useTranslation();
const current = choice[c.user_id] ?? null;
return (
<div className={ROW}>
<TripMemberAvatar username={c.username} avatarUrl={c.avatar_url} />
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>
{c.username}
</span>
{c.is_trip_owner && (
<span
className="rounded-full bg-surface-tertiary px-2 py-[2px] font-semibold text-content-muted"
style={fs(10.5)}
>
{t('members.owner')}
</span>
)}
{showAccess && c.access && (
<span className="rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success" style={fs(10.5)}>
{t('trip.journal.hasAccess')}
</span>
)}
</div>
<div
role="radiogroup"
aria-label={c.username}
className="flex flex-none gap-0.5 rounded-full bg-surface-card p-0.5 ring-1 ring-edge-faint"
>
{ROLES.map((r) => (
<button
key={r.labelKey}
type="button"
role="radio"
aria-checked={current === r.value}
onClick={() => onRole(c.user_id, r.value)}
className={`${SEGMENT} ${current === r.value ? 'bg-accent text-accent-text' : 'text-content-muted hover:text-content'}`}
style={fs(11, 'body')}
>
{t(r.labelKey)}
</button>
))}
</div>
</div>
);
}
@@ -122,10 +122,10 @@ describe('TripMembersModal', () => {
expect(document.body).toBeInTheDocument(); expect(document.body).toBeInTheDocument();
}); });
it('FE-COMP-MEMBERS-002: shows Share Trip title', () => { it('FE-COMP-MEMBERS-002: shows the Trip settings title', () => {
render(<TripMembersModal {...defaultProps} />); render(<TripMembersModal {...defaultProps} />);
// members.shareTrip = "Share Trip" // trip.settings.title = "Trip settings"
expect(screen.getByText('Share Trip')).toBeInTheDocument(); expect(screen.getByText('Trip settings')).toBeInTheDocument();
}); });
it('FE-COMP-MEMBERS-003: shows owner username after load', async () => { it('FE-COMP-MEMBERS-003: shows owner username after load', async () => {
@@ -245,7 +245,7 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => { it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => {
render(<TripMembersModal {...defaultProps} isOpen={true} />); render(<TripMembersModal {...defaultProps} isOpen={true} />);
expect(screen.getByText('Share Trip')).toBeInTheDocument(); expect(screen.getByText('Trip settings')).toBeInTheDocument();
}); });
// ── Share Link Section (016-021) ─────────────────────────────────────────── // ── Share Link Section (016-021) ───────────────────────────────────────────
@@ -291,6 +291,7 @@ describe('TripMembersModal', () => {
); );
render(<TripMembersModal {...defaultProps} />); render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
const createBtn = await screen.findByText('Create link'); const createBtn = await screen.findByText('Create link');
await user.click(createBtn); await user.click(createBtn);
@@ -689,6 +690,7 @@ describe('TripMembersModal', () => {
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({ success: true })), http.delete('/api/trips/1/invite-link', () => HttpResponse.json({ success: true })),
); );
const view = render(<TripMembersModal {...defaultProps} />); const view = render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
const createBtn = await screen.findByRole('button', { name: /Create invite link/i }); const createBtn = await screen.findByRole('button', { name: /Create invite link/i });
fireEvent.click(createBtn); fireEvent.click(createBtn);
@@ -719,6 +721,7 @@ describe('TripMembersModal', () => {
asShareOwner(); asShareOwner();
server.use(http.post('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 }))); server.use(http.post('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })));
render(<TripMembersModal {...defaultProps} />); render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
await user.click(await screen.findByRole('button', { name: /Create invite link/i })); await user.click(await screen.findByRole('button', { name: /Create invite link/i }));
@@ -734,6 +737,7 @@ describe('TripMembersModal', () => {
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })), http.delete('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })),
); );
render(<TripMembersModal {...defaultProps} />); render(<TripMembersModal {...defaultProps} />);
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
await user.click(await screen.findByRole('button', { name: /Disable/i })); await user.click(await screen.findByRole('button', { name: /Disable/i }));
@@ -1,3 +1,4 @@
import { TripJournalLinkSection } from './TripJournalLinkSection'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react' import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
@@ -181,7 +182,7 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
} }
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl"> <Modal isOpen={isOpen} onClose={onClose} title={t('trip.settings.title')} size="3xl">
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid"> <div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style> <style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
@@ -402,6 +403,8 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
<TripInviteLinkSection tripId={tripId} t={t} /> <TripInviteLinkSection tripId={tripId} t={t} />
</div>} </div>}
<TripJournalLinkSection tripId={tripId} />
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style> <style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
</div> </div>
</Modal> </Modal>
+81 -11
View File
@@ -1,12 +1,15 @@
import { useId, useState, type ReactNode } from 'react' import { useId, useState, type ReactNode } from 'react'
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react' import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Settings, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import ConfirmDialog from '../shared/ConfirmDialog' import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip' import { Tooltip } from '../shared/Tooltip'
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell' import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts' import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts'
import { LinkedJournalIcon } from './LinkedJournalIcon'
import { TripJournalLinkSection } from './TripJournalLinkSection'
import { TripMemberAvatar } from './TripMemberAvatar' import { TripMemberAvatar } from './TripMemberAvatar'
import { useTripJournalSectionVisible } from './useTripJournalLink'
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare' import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare'
interface TripShareDialogProps { interface TripShareDialogProps {
@@ -29,18 +32,38 @@ const COUNT = 'rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bo
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5' const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card' const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card'
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold' const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
/** A fixed height on a wide screen, so the dialog does not jump when another area is picked. */
const BODY = 'flex min-h-0 flex-1 max-md:flex-col md:h-[min(640px,calc(100dvh-240px))]'
const NAV = 'flex flex-none gap-1 border-edge-faint bg-surface-secondary p-3 md:w-[236px] md:flex-col md:border-e max-md:overflow-x-auto max-md:border-b'
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50' const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
/** The desktop dialog behind Share: who is on the trip, its guests, and the two links that let others in. */ /**
* The trip's Settings on the desktop: who is on the trip, its guests, the two
* links that let others in, and the journal the trip shows.
*/
export default function TripShareDialog(props: TripShareDialogProps) { export default function TripShareDialog(props: TripShareDialogProps) {
return props.isOpen ? <OpenDialog {...props} /> : null return props.isOpen ? <OpenDialog {...props} /> : null
} }
type Area = 'people' | 'sharing' | 'journal'
/** The areas of the dialog, each with its icon, its name and the line under the name. */
const AREAS: { id: Area; icon: (on: boolean) => ReactNode; labelKey: string; hintKey: string }[] = [
{ id: 'people', icon: () => <Users size={16} strokeWidth={2} />, labelKey: 'trip.settings.people', hintKey: 'trip.settings.peopleHint' },
{ id: 'sharing', icon: () => <Link2 size={16} strokeWidth={2} />, labelKey: 'trip.settings.sharing', hintKey: 'trip.settings.sharingHint' },
{ id: 'journal', icon: () => <LinkedJournalIcon size={16} />, labelKey: 'trip.journal.title', hintKey: 'trip.journal.hint' },
]
function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareDialogProps) { function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const labelId = useId() const labelId = useId()
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged }) const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
const [ask, setAsk] = useState<Ask | null>(null) const [ask, setAsk] = useState<Ask | null>(null)
const [area, setArea] = useState<Area>('people')
const journalSection = useTripJournalSectionVisible(tripId)
const areas = AREAS.filter(a => a.id === 'people' || (a.id === 'sharing' ? m.canManageShare : journalSection))
const current = areas.find(a => a.id === area) ?? areas[0]
const panelOf = (id: Area) => areas.find(a => a.id === id)
const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}` const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}`
return ( return (
@@ -48,15 +71,16 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
<DialogShell <DialogShell
onClose={onClose} onClose={onClose}
labelledBy={labelId} labelledBy={labelId}
width={m.canManageShare ? 'wide' : 'detail'} width={areas.length > 1 ? 'wide' : 'detail'}
blocked={!!ask} blocked={!!ask}
bodyClassName={BODY}
header={( header={(
<DialogHeader <DialogHeader
tile={<DialogTile><Users size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>} tile={<DialogTile><Settings size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT} tint={NEUTRAL_TINT}
labelId={labelId} labelId={labelId}
onClose={onClose} onClose={onClose}
eyebrow={t('members.shareTrip')} eyebrow={t('trip.settings.title')}
title={tripTitle} title={tripTitle}
pills={m.loading ? undefined : ( pills={m.loading ? undefined : (
<> <>
@@ -67,17 +91,24 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
/> />
)} )}
> >
<div className={m.canManageShare ? 'grid grid-cols-2 items-start gap-6 max-md:grid-cols-1' : ''}> {areas.length > 1 && <AreaNav areas={areas} current={current.id} onPick={setArea} />}
<div className="flex flex-col gap-5"> <div className="min-h-0 flex-1 overflow-y-auto px-7 pb-7 pt-6 max-sm:px-5">
{/* Every area stays mounted, so its links and lists are loaded by the time it is picked. */}
<AreaPanel area={panelOf('people')} shown={current.id === 'people'} titled={areas.length > 1}>
{m.canManageMembers && <InviteUser m={m} />} {m.canManageMembers && <InviteUser m={m} />}
<MembersList m={m} onAsk={setAsk} /> <MembersList m={m} onAsk={setAsk} />
{(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />} {(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />}
</div> </AreaPanel>
{m.canManageShare && ( {m.canManageShare && (
<div className="flex flex-col gap-5"> <AreaPanel area={panelOf('sharing')} shown={current.id === 'sharing'} titled>
<PublicLink tripId={tripId} /> <PublicLink tripId={tripId} />
<InviteLink tripId={tripId} /> <InviteLink tripId={tripId} />
</div> </AreaPanel>
)}
{journalSection && (
<AreaPanel area={panelOf('journal')} shown={current.id === 'journal'} titled>
<TripJournalLinkSection tripId={tripId} framed={false} />
</AreaPanel>
)} )}
</div> </div>
</DialogShell> </DialogShell>
@@ -93,6 +124,45 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
) )
} }
/** The areas down the side on a wide screen, a row of pills above the content on a narrow one. */
function AreaNav({ areas, current, onPick }: { areas: typeof AREAS; current: Area; onPick: (area: Area) => void }) {
const { t } = useTranslation()
return (
<nav aria-label={t('trip.settings.title')} className={NAV}>
{areas.map(a => {
const on = a.id === current
return (
<button key={a.id} type="button" aria-current={on ? 'page' : undefined} aria-label={t(a.labelKey)} onClick={() => onPick(a.id)}
className={`flex flex-none items-center gap-2.5 rounded-[12px] px-2.5 py-2 text-start transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-muted hover:bg-surface-hover hover:text-content'}`}>
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] ${on ? 'bg-accent text-accent-text' : 'bg-surface-tertiary'}`}>{a.icon(on)}</span>
<span className="min-w-0">
<span className="block truncate font-semibold" style={fs(13, 'body')}>{t(a.labelKey)}</span>
<span className="block truncate text-content-faint max-md:hidden" style={fs(11)}>{t(a.hintKey)}</span>
</span>
</button>
)
})}
</nav>
)
}
/** One area: its name and what it is for, then its parts. Hidden rather than unmounted while another one shows. */
function AreaPanel({ area, shown, titled, children }: { area: (typeof AREAS)[number] | undefined; shown: boolean; titled: boolean; children: ReactNode }) {
const { t } = useTranslation()
return (
// The class, not the hidden attribute alone: flex would win over the attribute's display:none.
<section hidden={!shown} className={shown ? 'flex flex-col gap-5' : 'hidden'}>
{titled && area && (
<header>
<h3 className="m-0 font-semibold text-content" style={fs(17, 'subtitle')}>{t(area.labelKey)}</h3>
<p className="m-0 mt-1 max-w-[560px] leading-normal text-content-muted" style={fs(12.5, 'body')}>{t(area.hintKey)}</p>
</header>
)}
{children}
</section>
)
}
function InviteUser({ m }: { m: TripMembersState }) { function InviteUser({ m }: { m: TripMembersState }) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
@@ -291,7 +361,7 @@ function LinkLine({ url, copied, onCopy }: { url: string; copied: boolean; onCop
) )
} }
function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) { export function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
return ( return (
<section className={PANEL}> <section className={PANEL}>
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
@@ -0,0 +1,290 @@
// FE-TJL-HOOK-001 to FE-TJL-HOOK-014
/**
* The trip's linked journal as the planner and the Settings dialog read it.
*
* `useTripJournal` decides whether the trip has a Journal tab at all: only with
* the Journey addon on, only once the server answered, and live on every
* `trip:journal:changed`. `useTripJournalLink` is the Settings section: pick one
* of your own journals, everybody of the trip ticked as a reader by default,
* the people who would get in for the first time named before the save.
*/
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { seedStore } from '../../../tests/helpers/store';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
import { useTripJournal, useTripJournalLink, useTripJournalSectionVisible } from './useTripJournalLink';
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const listeners = new Set<(e: Record<string, unknown>) => void>();
vi.mock('../../api/websocket', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/websocket')>()),
addListener: (fn: (e: Record<string, unknown>) => void) => {
listeners.add(fn);
},
removeListener: (fn: (e: Record<string, unknown>) => void) => {
listeners.delete(fn);
},
}));
const TRIP = 4;
const ME = 1;
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
const LINKED: TripJournalLinkResponse = {
link: {
journey_id: 9,
title: 'Japan',
subtitle: null,
cover_image: null,
owner_id: ME,
linked_by: ME,
created_at: 1,
my_role: 'owner',
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
},
can_link: true,
can_unlink: true,
can_edit_members: true,
};
const CANDIDATES: TripJournalCandidate[] = [
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: 'editor' },
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
];
let gets = 0;
let answer: TripJournalLinkResponse = EMPTY;
const writes: { method: string; body: unknown }[] = [];
function routes({ writeStatus = 200 } = {}) {
server.use(
http.get('/api/trips/:tripId/journal', () => {
gets += 1;
return HttpResponse.json(answer);
}),
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
http.get('/api/journeys', () =>
HttpResponse.json({
journeys: [
{ id: 9, user_id: ME, title: 'Japan' },
{ id: 10, user_id: 99, title: 'Not mine' },
],
})
),
http.put('/api/trips/:tripId/journal', async ({ request }) => {
writes.push({ method: 'PUT', body: await request.json() });
return writeStatus === 200
? HttpResponse.json(LINKED)
: HttpResponse.json({ error: 'no' }, { status: writeStatus });
}),
http.patch('/api/trips/:tripId/journal/members', async ({ request }) => {
writes.push({ method: 'PATCH', body: await request.json() });
return HttpResponse.json(LINKED);
}),
http.delete('/api/trips/:tripId/journal', () => {
writes.push({ method: 'DELETE', body: null });
return HttpResponse.json(EMPTY);
})
);
}
function journeyAddon(enabled: boolean): void {
useAddonStore.setState({
loaded: true,
addons: enabled ? [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }] : [],
});
}
beforeEach(() => {
gets = 0;
answer = EMPTY;
writes.length = 0;
listeners.clear();
vi.clearAllMocks();
useTripJournalLinkStore.setState({ byTrip: {} });
seedStore(useAuthStore, { user: buildUser({ id: ME }) });
journeyAddon(true);
routes();
});
afterEach(() => _resetNetworkMode());
describe('useTripJournal', () => {
it('FE-TJL-HOOK-001: with the Journey addon off it asks nothing and there is no journal', async () => {
journeyAddon(false);
const { result } = renderHook(() => useTripJournal(TRIP));
expect(result.current.openJournalId).toBeNull();
expect(result.current.settled).toBe(true);
await new Promise((r) => setTimeout(r, 30));
expect(gets).toBe(0);
});
it('FE-TJL-HOOK-002: a linked journal open to this person is the one the tab opens', async () => {
answer = LINKED;
const { result } = renderHook(() => useTripJournal(TRIP));
expect(result.current.settled).toBe(false);
await waitFor(() => expect(result.current.openJournalId).toBe(9));
expect(result.current.settled).toBe(true);
});
it('FE-TJL-HOOK-003: a link that leaves this person out gives no tab', async () => {
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, my_role: null } };
const { result } = renderHook(() => useTripJournal(TRIP));
await waitFor(() => expect(result.current.link).not.toBeNull());
expect(result.current.openJournalId).toBeNull();
});
it('FE-TJL-HOOK-004: trip:journal:changed asks again', async () => {
const { result } = renderHook(() => useTripJournal(TRIP));
await waitFor(() => expect(gets).toBe(1));
answer = LINKED;
act(() => {
for (const fn of [...listeners]) fn({ type: 'trip:journal:changed' });
});
await waitFor(() => expect(result.current.openJournalId).toBe(9));
});
it('FE-TJL-HOOK-005: offline it asks nothing and is settled', () => {
setForcedOffline(true);
const { result } = renderHook(() => useTripJournal(TRIP));
expect(result.current.settled).toBe(true);
expect(gets).toBe(0);
});
it('FE-TJL-HOOK-006: without a trip id it asks nothing', async () => {
renderHook(() => useTripJournal(null));
await new Promise((r) => setTimeout(r, 30));
expect(gets).toBe(0);
});
it('FE-TJL-HOOK-007: the Settings section shows for someone who may link, and not for someone who may not', async () => {
const { result, rerender } = renderHook(() => useTripJournalSectionVisible(TRIP));
await waitFor(() => expect(result.current).toBe(true));
act(() => useTripJournalLinkStore.setState({ byTrip: { [TRIP]: { ...EMPTY, can_link: false } } }));
rerender();
expect(result.current).toBe(false);
});
});
describe('useTripJournalLink', () => {
it('FE-TJL-HOOK-008: picking offers only my journals and ticks everybody as a reader', async () => {
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
await waitFor(() => expect(result.current.journals).toEqual([{ id: 9, title: 'Japan', subtitle: null }]));
act(() => result.current.pickJournal(9));
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
expect(result.current.choice).toEqual({ 2: 'viewer', 3: 'viewer' });
// lena is in already; tom would be let in for the first time.
expect(result.current.newlyGranted.map((c) => c.username)).toEqual(['tom']);
expect(result.current.canSave).toBe(true);
});
it('FE-TJL-HOOK-009: saving a pick links with the chosen roles and goes back to the view', async () => {
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
act(() => result.current.pickJournal(9));
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
act(() => {
result.current.setRole(2, 'editor');
result.current.setRole(3, null);
});
expect(result.current.newlyGranted).toEqual([]);
await act(async () => {
await result.current.save();
});
expect(writes).toEqual([{ method: 'PUT', body: { journey_id: 9, members: [{ user_id: 2, role: 'editor' }] } }]);
expect(toast.success).toHaveBeenCalledWith('trip.journal.linked');
expect(result.current.mode).toBe('view');
expect(result.current.link?.title).toBe('Japan');
});
it('FE-TJL-HOOK-010: editing members starts from who the link holds and saves them', async () => {
answer = LINKED;
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.link).not.toBeNull());
act(() => result.current.editMembers());
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
expect(result.current.choice).toEqual({ 2: 'editor', 3: null });
act(() => result.current.setAll('viewer'));
await act(async () => {
await result.current.save();
});
expect(writes).toEqual([
{
method: 'PATCH',
body: {
members: [
{ user_id: 2, role: 'viewer' },
{ user_id: 3, role: 'viewer' },
],
},
},
]);
expect(toast.success).toHaveBeenCalledWith('trip.journal.membersSaved');
});
it('FE-TJL-HOOK-011: unlinking takes the journal off the trip', async () => {
answer = LINKED;
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.canUnlink).toBe(true));
await act(async () => {
await result.current.unlink();
});
expect(writes).toEqual([{ method: 'DELETE', body: null }]);
expect(result.current.link).toBeNull();
expect(toast.success).toHaveBeenCalledWith('trip.journal.unlinked');
});
it('FE-TJL-HOOK-012: a refused save says it was not allowed and keeps the form open', async () => {
routes({ writeStatus: 403 });
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
act(() => result.current.pickJournal(9));
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
await act(async () => {
await result.current.save();
});
expect(toast.error).toHaveBeenCalledWith('trip.journal.errorForbidden');
expect(result.current.mode).toBe('pick');
expect(result.current.busy).toBe(false);
});
it('FE-TJL-HOOK-013: offline, picking says a connection is needed', async () => {
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
setForcedOffline(true);
act(() => result.current.startPick());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('trip.journal.errorOffline'));
expect(result.current.journals).toEqual([]);
});
it('FE-TJL-HOOK-014: a failing candidate list is reported and cancel goes back to the view', async () => {
server.use(
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ error: 'x' }, { status: 500 }))
);
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
act(() => result.current.pickJournal(9));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('trip.journal.errorGeneric'));
expect(result.current.candidates).toEqual([]);
act(() => result.current.cancel());
expect(result.current.mode).toBe('view');
expect(result.current.canSave).toBe(false);
});
});
@@ -0,0 +1,243 @@
import type { TripJournalCandidate, TripJournalLinkMember, TripJournalLinkRole } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { useTranslation } from '../../i18n';
import { tripJournalLinkRepo, TripJournalOfflineError, type OwnJournal } from '../../repo/tripJournalLinkRepo';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
import { useServerPing } from '../../sync/useServerPing';
import { useToast } from '../shared/Toast';
/** Who a link lets in, as the member list edits it: a role, or left out. */
export type JournalMemberChoice = Record<number, TripJournalLinkRole | null>;
/** One empty list for "the link holds nobody", so the member effect does not rerun on every render. */
const NOBODY: TripJournalLinkMember[] = [];
/** The status of a failed request, when the server gave one. */
function statusOf(err: unknown): number | undefined {
return (err as { response?: { status?: number } })?.response?.status;
}
/**
* The trip's linked journal as everybody on the trip reads it: answered once per
* trip from the store, asked again on mount, when the connection returns and on
* every `trip:journal:changed`. With the Journey addon off there is no journal.
*/
export function useTripJournal(tripId: number | null | undefined) {
const addonsLoaded = useAddonStore((s) => s.loaded);
const journeyOn = useAddonStore((s) => s.isEnabled('journey'));
const answer = useTripJournalLinkStore((s) => (tripId != null ? s.byTrip[String(tripId)] : undefined));
const refresh = useTripJournalLinkStore((s) => s.refresh);
const { offline } = useNetworkMode();
const active = journeyOn && tripId != null && !offline;
const reload = useCallback(async () => {
if (tripId != null) await refresh(tripId);
}, [tripId, refresh]);
useEffect(() => {
if (active) void reload();
}, [active, reload]);
useServerPing('trip:journal:changed', active, reload);
const link = journeyOn ? (answer?.link ?? null) : null;
return {
journeyOn,
answer: journeyOn ? answer : undefined,
link,
/** The journal the trip's Journal tab opens: linked, and open to this person. */
openJournalId: link?.my_role ? link.journey_id : null,
/** Whether the answer is in, or none is coming (addon off, offline): the planner may drop the tab then. */
settled: addonsLoaded && (!journeyOn || offline || answer !== undefined),
};
}
/** Whether the trip's Settings carry a Journal section for this person, for laying the dialog out. */
export function useTripJournalSectionVisible(tripId: number): boolean {
const { journeyOn, answer } = useTripJournal(tripId);
return journeyOn && !!answer && (answer.can_link || !!answer.link);
}
/** The toast for a failed journal request: offline, refused, or anything else. */
function useJournalFailure() {
const { t } = useTranslation();
const toast = useToast();
return useCallback(
(err: unknown) => {
if (err instanceof TripJournalOfflineError) toast.error(t('trip.journal.errorOffline'));
else if (statusOf(err) === 403) toast.error(t('trip.journal.errorForbidden'));
else toast.error(t('trip.journal.errorGeneric'));
},
[t, toast]
);
}
/**
* The people of the trip for one journal, each with the role the save would
* give them. `current` is who the link holds now, to start from; without it
* everybody starts as a reader, the default the section promises.
*
* What loaded is kept with the journal it belongs to, so picking another one
* shows nothing until its own people are in, never the last journal's.
*/
function useJournalMemberChoice(
tripId: number,
journeyId: number | null,
current: TripJournalLinkMember[] | null,
fail: (err: unknown) => void
) {
const [loaded, setLoaded] = useState<{ journeyId: number; list: TripJournalCandidate[] } | null>(null);
const [choice, setChoice] = useState<JournalMemberChoice>({});
useEffect(() => {
if (journeyId == null) return;
let cancelled = false;
tripJournalLinkRepo
.candidates(tripId, journeyId)
.then((list) => {
if (cancelled) return;
const held = new Map((current ?? []).map((m) => [m.user_id, m.role]));
setLoaded({ journeyId, list });
setChoice(Object.fromEntries(list.map((c) => [c.user_id, current ? (held.get(c.user_id) ?? null) : 'viewer'])));
})
.catch((err: unknown) => {
if (cancelled) return;
fail(err);
setLoaded({ journeyId, list: [] });
});
return () => {
cancelled = true;
};
}, [tripId, journeyId, current, fail]);
const candidates = journeyId != null && loaded?.journeyId === journeyId ? loaded.list : null;
const setRole = useCallback((memberId: number, role: TripJournalLinkRole | null) => {
setChoice((prev) => ({ ...prev, [memberId]: role }));
}, []);
const setAll = useCallback(
(role: TripJournalLinkRole | null) =>
setChoice(Object.fromEntries((candidates ?? []).map((c) => [c.user_id, role]))),
[candidates]
);
const chosenMembers = useMemo(
() => Object.entries(choice).flatMap(([id, role]) => (role ? [{ user_id: Number(id), role }] : [])),
[choice]
);
/** The people the save would let into the journal for the first time; the warning names them. */
const newlyGranted = useMemo(
() => (candidates ?? []).filter((c) => c.access === null && choice[c.user_id]),
[candidates, choice]
);
return { candidates, choice, setRole, setAll, chosenMembers, newlyGranted };
}
/**
* The Journal section of the trip's Settings: link one of your journals, choose
* who it is open to and as what, change it, or take it off. One hook for the
* desktop dialog and the phone sheet, so both offer and warn about the same thing.
*
* Picking a journal asks the server who of the trip already has access to it;
* whoever would be let in for the first time is named before anything is saved,
* because they will see the journal in their own Journey list from then on.
*/
export function useTripJournalLink(tripId: number) {
const { t } = useTranslation();
const toast = useToast();
const fail = useJournalFailure();
const userId = useAuthStore((s) => s.user?.id ?? null);
const { journeyOn, answer, link } = useTripJournal(tripId);
const store = useTripJournalLinkStore.getState;
/** 'view' shows the link; 'pick' chooses a journal; 'members' changes who it is open to. */
const [mode, setMode] = useState<'view' | 'pick' | 'members'>('view');
const [journals, setJournals] = useState<OwnJournal[] | null>(null);
const [pickedId, setPickedId] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const journeyId = mode === 'members' ? (link?.journey_id ?? null) : mode === 'pick' ? pickedId : null;
// Who the link holds, to start from: when editing it, or when re-picking the
// journal it already shows with people on it.
const held = link?.members ?? NOBODY;
const current = mode === 'members' || (link?.journey_id === pickedId && held.length > 0) ? held : null;
const members = useJournalMemberChoice(tripId, journeyId, current, fail);
const { chosenMembers } = members;
const startPick = useCallback(() => {
setMode('pick');
setPickedId(null);
if (userId == null) return;
tripJournalLinkRepo
.ownJournals(userId)
.then(setJournals)
.catch((err: unknown) => {
fail(err);
setJournals([]);
});
}, [userId, fail]);
const editMembers = useCallback(() => setMode('members'), []);
const cancel = useCallback(() => {
setMode('view');
setPickedId(null);
}, []);
const run = useCallback(
async (write: () => Promise<void>, done: string) => {
setBusy(true);
try {
await write();
toast.success(done);
cancel();
} catch (err: unknown) {
fail(err);
} finally {
setBusy(false);
}
},
[cancel, fail, toast]
);
const save = useCallback(async () => {
if (mode === 'pick' && pickedId != null) {
await run(() => store().link(tripId, pickedId, chosenMembers), t('trip.journal.linked'));
} else if (mode === 'members') {
await run(() => store().setMembers(tripId, chosenMembers), t('trip.journal.membersSaved'));
}
}, [mode, pickedId, run, store, tripId, chosenMembers, t]);
const unlink = useCallback(async () => {
await run(() => store().unlink(tripId), t('trip.journal.unlinked'));
}, [run, store, tripId, t]);
return {
/** The section shows at all: the addon is on and this person may link, or the trip has a journal they see. */
visible: journeyOn && !!answer && (answer.can_link || !!answer.link),
link,
canLink: !!answer?.can_link,
canUnlink: !!answer?.can_unlink,
canEditMembers: !!answer?.can_edit_members,
mode,
startPick,
cancel,
editMembers,
journals,
pickedId,
pickJournal: setPickedId,
candidates: members.candidates,
choice: members.choice,
setRole: members.setRole,
setAll: members.setAll,
newlyGranted: members.newlyGranted,
canSave: !busy && (mode === 'members' || (mode === 'pick' && pickedId != null)),
busy,
save,
unlink,
};
}
+2
View File
@@ -20,6 +20,7 @@ export const TRIP_TAB_IDS = [
'finanzplan', 'finanzplan',
'dateien', 'dateien',
'collab', 'collab',
'journal',
] as const ] as const
export type TripTabId = (typeof TRIP_TAB_IDS)[number] export type TripTabId = (typeof TRIP_TAB_IDS)[number]
@@ -40,6 +41,7 @@ export const TRIP_TAB_LABEL_KEYS: Record<TripTabId, string> = {
finanzplan: 'trip.tabs.budget', finanzplan: 'trip.tabs.budget',
dateien: 'trip.tabs.files', dateien: 'trip.tabs.files',
collab: 'admin.addons.catalog.collab.name', collab: 'admin.addons.catalog.collab.name',
journal: 'trip.tabs.journal',
} }
export const isTripTabId = (value: unknown): value is TripTabId => export const isTripTabId = (value: unknown): value is TripTabId =>
@@ -7,7 +7,7 @@ import ContributorInviteDialog from '../../../components/Journey/ContributorInvi
import ConfirmDialog from '../../../components/shared/ConfirmDialog' import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker' import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers' import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail' import { useJourneyDetail, type JourneyDetailOptions } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyStore } from '../../../store/journeyStore' import { useJourneyStore } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore' import { useAddonStore } from '../../../store/addonStore'
import DawarichIcon from '../../../components/shared/DawarichIcon' import DawarichIcon from '../../../components/shared/DawarichIcon'
@@ -32,8 +32,12 @@ import MJourneySettingsSheet from './MJourneySettingsSheet'
* Journey detail — integrated map with the horizontal 280px card timeline * Journey detail — integrated map with the horizontal 280px card timeline
* (Journey tab) and the 2-column photo gallery (Gallery tab). Cards and map * (Journey tab) and the 2-column photo gallery (Gallery tab). Cards and map
* markers stay in sync; tapping the centered card opens the entry sheet. * markers stay in sync; tapping the centered card opens the entry sheet.
*
* A trip that links the journal shows this same screen in its own tab
* (`embedded`): the trip's back button and controls sit on top, so the screen
* drops its own back button and leaves the trip's URL alone.
*/ */
export default function MJourneyDetail() { export default function MJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
const { const {
id, navigate, toast, t, id, navigate, toast, t,
current, loading, current, loading,
@@ -48,7 +52,7 @@ export default function MJourneyDetail() {
dismissSuggestion, restoreSuggestions, openAtEntryId, dismissSuggestion, restoreSuggestions, openAtEntryId,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
addPickedProviderPhotos, addEntryProviderPhotos, addPickedProviderPhotos, addEntryProviderPhotos,
} = useJourneyDetail() } = useJourneyDetail({ journeyId, embedded })
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the // The dock's FAB is a sibling of this screen: on the Gallery it becomes the
// upload button, so it has to know which tab is open — and it stops knowing // upload button, so it has to know which tab is open — and it stops knowing
@@ -175,7 +179,7 @@ export default function MJourneyDetail() {
const [params, setParams] = useSearchParams() const [params, setParams] = useSearchParams()
const [pendingUpload, setPendingUpload] = useState(false) const [pendingUpload, setPendingUpload] = useState(false)
useEffect(() => { useEffect(() => {
if (params.get('create') !== 'photo') return if (embedded || params.get('create') !== 'photo') return
setParams(prev => { setParams(prev => {
const next = new URLSearchParams(prev) const next = new URLSearchParams(prev)
next.delete('create') next.delete('create')
@@ -184,7 +188,7 @@ export default function MJourneyDetail() {
if (!canEditEntries) return if (!canEditEntries) return
setView('gallery') setView('gallery')
setPendingUpload(true) setPendingUpload(true)
}, [params, setParams, canEditEntries, setView]) }, [params, setParams, canEditEntries, setView, embedded])
useEffect(() => { useEffect(() => {
if (!pendingUpload || !providersReady) return if (!pendingUpload || !providersReady) return
@@ -331,6 +335,9 @@ export default function MJourneyDetail() {
{/* Header: back — segment — upload / overflow menu */} {/* Header: back — segment — upload / overflow menu */}
<div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2"> <div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2">
{embedded ? (
<span className="w-[38px] flex-none" aria-hidden />
) : (
<button <button
type="button" type="button"
onClick={() => navigate('/journey')} onClick={() => navigate('/journey')}
@@ -339,6 +346,7 @@ export default function MJourneyDetail() {
> >
<ChevronLeft size={18} strokeWidth={2.2} /> <ChevronLeft size={18} strokeWidth={2.2} />
</button> </button>
)}
<div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"> <div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]">
<button <button
type="button" type="button"
@@ -1,14 +1,11 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2 } from 'lucide-react'
X, Image, Plus, Trash2, UserPlus, Link as LinkIcon,
List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2,
} from 'lucide-react'
import MSheet from '../../components/MSheet' import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn' import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle' import MToggle from '../../components/MToggle'
import ConfirmDialog from '../../../components/shared/ConfirmDialog' import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { useTranslation } from '../../../i18n' import { useTranslation } from '../../../i18n'
import type { JourneyDetail } from '../../../store/journeyStore' import type { JourneyDetail, JourneyTripAccess } from '../../../store/journeyStore'
import { useJourneySettings, useJourneyTripLinking } from '../../../components/Journey/useJourneySettings' import { useJourneySettings, useJourneyTripLinking } from '../../../components/Journey/useJourneySettings'
import { useJourneyShareLink } from '../../../components/Journey/useJourneyShareLink' import { useJourneyShareLink } from '../../../components/Journey/useJourneyShareLink'
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers' import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
@@ -237,6 +234,8 @@ export default function MJourneySettingsSheet({
{t('journey.contributors.invite')} {t('journey.contributors.invite')}
</button> </button>
<MTripAccessList access={journey.trip_access} eyebrow={eyebrow} />
{/* Public share */} {/* Public share */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div> <div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div>
{!shareLink ? ( {!shareLink ? (
@@ -333,3 +332,27 @@ export default function MJourneySettingsSheet({
</MSheet> </MSheet>
) )
} }
/** Who a trip's linked journal lets in (#2683): shown under the contributors, but changed in that trip. */
function MTripAccessList({ access, eyebrow }: { access: JourneyTripAccess[] | undefined; eyebrow: string }) {
const { t } = useTranslation()
if (!access?.length) return null
return (
<>
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.contributors.viaTrips')}</div>
<p className="m-0 mb-[6px] text-caption text-m-faint">{t('journey.contributors.viaTripsHint')}</p>
{access.map(a => (
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-[10px] px-[2px] py-1">
<Link2 size={15} className="flex-none text-m-muted" />
<span className="min-w-0 flex-1">
<span className="block truncate text-caption font-bold">{a.username}</span>
<span className="block truncate text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
</span>
<span className="rounded-full bg-[color:var(--m-ic)] px-[10px] py-[3px] font-geist text-caption font-extrabold text-m-muted">
{a.role}
</span>
</div>
))}
</>
)
}
@@ -1,4 +1,4 @@
import { ChevronRight, FileDown, Settings, Share2 } from 'lucide-react' import { ChevronRight, FileDown, Settings, Settings2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import MSheet from '../../../components/MSheet' import MSheet from '../../../components/MSheet'
import type { MTripSheetsProps } from '../MTripShell' import type { MTripSheetsProps } from '../MTripShell'
@@ -14,6 +14,7 @@ const TILE_COLORS: Record<string, string> = {
finanzplan: '#E8A33D', finanzplan: '#E8A33D',
dateien: '#68686F', dateien: '#68686F',
collab: '#EC4899', collab: '#EC4899',
journal: '#C2410C',
} }
/** /**
@@ -48,7 +49,7 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
} }
const actions: { id: string; icon: LucideIcon; label: string; go: () => void }[] = [ const actions: { id: string; icon: LucideIcon; label: string; go: () => void }[] = [
{ id: 'members', icon: Share2, label: t('members.shareTrip'), go: () => shell.openSheet('members') }, { id: 'members', icon: Settings2, label: t('trip.settings.title'), go: () => shell.openSheet('members') },
{ id: 'export', icon: FileDown, label: t('mobileTrip.export'), go: () => shell.openSheet('export') }, { id: 'export', icon: FileDown, label: t('mobileTrip.export'), go: () => shell.openSheet('export') },
...(canEditTrip ...(canEditTrip
? [{ id: 'tripedit', icon: Settings, label: t('dashboard.editTrip'), go: () => shell.openSheet('tripedit') }] ? [{ id: 'tripedit', icon: Settings, label: t('dashboard.editTrip'), go: () => shell.openSheet('tripedit') }]
@@ -7,6 +7,7 @@ import MFilesTab from './MFilesTab'
import MCollabTab from './MCollabTab' import MCollabTab from './MCollabTab'
import MListsTab from './MListsTab' import MListsTab from './MListsTab'
import MRoadtripTab from '../roadtrip/MRoadtripTab' import MRoadtripTab from '../roadtrip/MRoadtripTab'
import MJourneyDetail from '../../journey/MJourneyDetail'
import PluginFrame from '../../../../components/Plugins/PluginFrame' import PluginFrame from '../../../../components/Plugins/PluginFrame'
/** /**
@@ -61,6 +62,14 @@ export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProp
// switch between them must not lose it. // switch between them must not lose it.
case 'roadtrip': case 'roadtrip':
return <MRoadtripTab planner={planner} shell={shell} tab={tab} /> return <MRoadtripTab planner={planner} shell={shell} tab={tab} />
// The journal the trip links: the Journey addon's own phone screen, moved
// below the trip's floating top controls: its bars measure from --m-safe-top.
case 'journal':
return planner.journalId == null ? null : (
<div className="absolute inset-0 overflow-hidden [--m-safe-top:calc(env(safe-area-inset-top,0px)+62px)]">
<MJourneyDetail journeyId={planner.journalId} embedded />
</div>
)
default: default:
return <TabScroller>{null}</TabScroller> return <TabScroller>{null}</TabScroller>
} }

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