Compare commits

..

37 Commits

Author SHA1 Message Date
Maurice c42bbc4239 chore(native): re-trigger CI on the formatted tree 2026-11-05 17:22:51 +01:00
Maurice a9ffcbc134 style: prettier-format the native and i18n changes
The feature-branch CI does not run the formatting check, so the conflict-resolved
files (AboutTab, useAccountSettings, useTripExport), the native shell and iap
module, and the i18n files touched by the translation scripts were never run
through Prettier. Format them all; no behaviour changes.
2026-11-05 17:11:47 +01:00
Maurice 91defd40fd fix(native): a flat redeem result, so the claim flow typechecks
The discriminated union did not narrow under the native-shell tsconfig, so the
failure code read as absent on the whole type. Use a flat result with optional
instanceUrl and code instead: both are always present on the type, no narrowing
needed, and the ok plus instanceUrl guard still routes success from refusal.
2026-11-05 16:53:04 +01:00
Maurice bde3bb4a57 fix(native): typecheck, lint and knip on the native code; wire he's native bundle
The full suite, which the feature-branch CI does not run, catches four things the
native work still had: a non-narrowed union in the claim flow (tsc), a helper
named useFake that the react-hooks rule read as a hook (eslint), two iap types
exported but used only inside the file (knip), and the Hebrew native strings
present as a file but never spread into he's merged bundle (the aggregated key
parity test). All four fixed.
2026-11-05 16:44:31 +01:00
Maurice cc01f7df32 i18n(native): excuse the TREK Cloud brand, mark Email same-as-en, exempt the quota counts
The strict i18n checks, which the feature-branch CI does not run, catch three
things now that the native strings exist in every locale: TREK Cloud read as an
untranslated English phrase (it is a product name, like Vacay and Atlas, so add
it to the brand list); Email left identical in Italian, Indonesian and
Vietnamese, where it is the local word too (mark same-as-en) and in Greek (give
it a Greek term); and the GB and AI-document quota strings carrying {n} without
being plural groups (both are figures that never show a singular, like the other
unit-symbol counts in NOT_PLURAL).
2026-11-05 16:38:37 +01:00
Maurice 80de282e1a fix(i18n): add the Hebrew native strings, and correct the LoginPage size baseline
The he locale had every domain but native.ts, which broke i18n parity, the shared
coverage specs and the client parity test once the native strings existed. Add
he/native.ts with all native keys translated. Also correct the LoginPage size
baseline to its current line count.
2026-11-05 16:22:08 +01:00
Maurice 870b03c6c8 i18n(native): translate the app into the remaining 20 languages
The onboarding, plan picker, claim and provisioning strings were English
placeholders outside en, de and the five largest European languages. They are
now translated in full across the other 20 locales (ar, az, br, ca, cs, et, gr,
hu, id, ja, ko, ru, sk, sv, th, tr, uk, vi, zh, zh-TW), placeholders and brand
kept. The app now speaks every language TREK does.
2026-11-05 15:09:02 +01:00
Maurice 037e739807 feat(native): the in-app TREK Cloud subscription flow
After picking a plan, the app now carries the whole purchase itself when the build
can take one: choose your address (checked for free as you type, against the same
endpoint the website uses), your email, and subscribe. The receipt goes to
/api/iap/redeem, and a Setting up your TREK screen waits on the new address and
loads it the moment it answers.

The purchase sits behind an IapClient seam (src/native/iap.ts): a fake drives the
whole flow in development and preview, the native client reports itself
unavailable until the store plugin and products exist, and until then the plan
picker still finishes on the web, so nothing half-wired faces a customer. New
claim and provisioning strings across all locales (en, de and the five largest
European languages translated).
2026-11-05 14:36:21 +01:00
Maurice d02e58387d i18n(native): translate the onboarding into es, fr, it, nl and pl
The welcome, TREK Cloud and plan-picker strings were English placeholders in
every locale but en and de. The five largest remaining European languages are
now translated in full; the others still carry the English text and are a
follow-up for native speakers.
2026-11-05 14:15:23 +01:00
Maurice a6796765a2 feat(native): light status bar and a Go key on the address screen
The address screen sits on the near-black surface, so set the status bar to draw
its clock and icons in light while it is up; the server's own UI takes over the
style once it loads. The address field's on-screen return key now reads Go. Both
fail quietly where there is no native status bar (web, the simulator).
2026-11-05 14:00:36 +01:00
Maurice 5a62aa4541 feat(native): a plan picker for TREK Cloud, with live prices
The Get TREK Cloud path now leads to a plan picker that reads the live plans
from the site (/api/plans), so the prices and quotas always match the website.
Monthly and yearly in a segmented toggle, the year marked with its saving, each
plan a card with its price and what it includes. Subscribe is wired to finish on
the web order for now; it becomes an Apple/Google in-app purchase once the store
products exist. New strings across all locales (en and de translated).
2026-11-05 13:34:47 +01:00
Maurice 9790ad28e1 feat(native): a welcoming onboarding for the address screen
The app's own screen now opens on a welcome: the TREK mark, a line about what
TREK is, and two clear paths — connect the TREK you already open in the browser,
or get TREK Cloud. Both lead to polished screens: the address field with live
validation, a shake and haptic on a bad address and a success tick before the
server takes over; and a TREK Cloud intro with what it offers and a way in.
Screens slide and stagger in, buttons give a tap, and it all respects the safe
areas, light and dark, and reduced motion. Self-hosters are unaffected: cloud is
its own path, never in the way. New native strings are added across all locales
(English placeholders outside en/de, to translate).
2026-11-05 13:23:48 +01:00
Maurice 2ce44a12c5 Merge remote-tracking branch 'liketrek/dev' into resolve/native-apps
# Conflicts:
#	client/scripts/size-baseline.json
#	client/src/components/Planner/useTripExport.ts
#	client/src/components/Settings/AboutTab.tsx
#	client/src/components/Settings/useAccountSettings.ts
2026-11-05 13:07:08 +01:00
Maurice cb6149f8ba fix(native): read a notice button's href without a weak type
An action button carries no href, so a parameter typed as only an optional href refused the notice DTO; the button type now names its kind as well.
2026-10-25 18:58:27 +01:00
Maurice 8963180006 feat(native): everything the App Store and Google Play submissions need
- iOS: microphone usage text for "Take Video", no-export-compliance flag,
  ExportOptions.plist for App Store Connect uploads, the unused push
  forwarders gone; CI builds with Xcode 26 and lints the property lists.
- Android: version and signing from Gradle properties (unsigned without
  them), R8 with keep rules for TREK's and Capacitor's reflective classes,
  no Auto Backup of the session cookie, no external file provider path,
  template and Firebase leftovers removed, keystores and service accounts
  ignored, full-size adaptive icon layers with a monochrome layer; CI also
  builds the release APK.
- native-release.yml: signed bundle to a Play testing track and archive to
  TestFlight, by hand or on a v* tag, behind the store environment.
- Version handshake: the server reports nativeApi 1, the app refuses a
  server below its minimum and tolerates an app older than the offline copy.
- Address screen: privacy policy link, "Try the demo" and "What is a TREK
  server?"; the privacy link also in the app's server settings. Three new
  native strings in all 27 locales.
- Donation links and the notices asking for one are hidden in the app.
- Store listings for "TREK Travel Planner" in client/fastlane: texts, review
  notes with placeholders, privacy and account deletion page drafts, the
  feature graphic and icon, a length and image check in CI, and a Playwright
  store project with the scripts that turn its captures into screenshots.
- Docs: wiki/Native-Apps.md, README no longer says there is no store app.
2026-10-25 18:37:31 +01:00
Maurice 5c37dd43c5 fix(native): hide passkeys in the app through usePasskeys
The flag in useAccountSettings grew the identical destructuring in both account views past the duplication threshold; the shared passkeys hook already decides when its card is hidden, so the app check lives there now.
2026-10-25 18:02:45 +01:00
Maurice 6f3c051c7c fix(native): Prettier on the native client files, rate-limit e2e requests in sequence
Formats the files the client formatting gate listed, and NATIVE-E2E-005 sends its twenty exchange attempts one after another: all at once they reset the test server's connections.
2026-10-25 17:41:32 +01:00
Maurice 99c1a2d6c7 fix(native): strict types in the address screen, Prettier on the native-auth e2e suite, file-local exports
The address screen's element helper returns the plain element instead of Object.assign's intersection, which strict mode could not represent; the haptics test reads an optional argument as optional; three exports only their own file used are local now.
2026-10-25 17:26:03 +01:00
Maurice a3b04887a1 fix(native): new lint warnings and the MapLibre protocol type after the dev merge
Awaits that sat in loops in the native-auth tests run together, the hand-off hook marks its navigations as ignored, the native tests drop their non-null assertions, and the offline protocol answers with the data type MapLibre 6.15 expects.
2026-10-25 17:16:02 +01:00
Maurice 8b6a65023f Merge dev into feat/native-apps
Brings the native apps branch up to dev (NestJS-only server, MikroORM
repositories, tracked sessions, per-domain locales, the lint ratchets) and
ports its new code onto that architecture:

- server: /api/auth/native moves onto injected services. The session is
  minted by the auth domain's new LoginSessionService (login bookkeeping and
  SessionsService.issue in one transaction), the one-time codes live behind a
  NativeHandoffStore port with a capped in-memory implementation that drops
  expired codes on write instead of running a timer, both routes declare
  response contracts, and the exchange answers { success: true }. Unit and
  e2e suites run on createTestModule and the ORM factories.
- shared: native strings for az, et, sk and th, native wired into every
  locale, the address placeholder as a full URL.
- client: the account and export changes go into dev's shared hooks
  (useAccountSettings, useTripExport), the native server card is a
  desktop/phone pair on useNativeServer, the hand-off call moves out of the
  page hook into native/, best-effort native failures go through
  reportError instead of the console, and the address screen is known to
  knip and lint:i18n-keys.
- package-lock.json regenerated on Linux.
2026-10-25 17:04:15 +01:00
Maurice 7572c610a6 feat(native): TREK's own app icon and launch screen, named just TREK
The shells still carried Capacitor's placeholder icon and splash, the blue
X on a grid. They now show the white TREK mark on a dark, slightly glassy
tile: a soft light from the top left and a sheen over the upper half, in
the app's launch colour #09090b. Android 12+ draws its launch screen on the
same colour instead of light grey.

scripts/generate-app-icons.mjs renders the sources into assets/ from
public/icons/icon-white.svg, and `npm run icons:app` turns them into every
size both platforms ask for through @capacitor/assets. The Android
foreground mark is sized for the adaptive icon's round mask.

The home screen label is TREK: the old "TREK - Plan your trips!" was cut
off under the icon, and the longer title belongs to the store listings.
2026-09-24 14:50:25 +02:00
Maurice f567e15a99 fix(native): Android shows "server not reachable" instead of a blank page
Capacitor injects its bridge on Android only into the server's origin once
server.url is set, so the local error page it switches to when the server
does not load had no bridge. It could neither retry nor change the server
and stayed blank.

TrekWebViewClient now catches a failed page load (a network error, or a 5xx
from a proxy while TREK restarts) and the app starts over on the bundled
address screen, where the bridge is present and the screen offers exactly
those two choices. The server config no longer carries errorPath.

Two traps on the way, both found in the emulator: a restart during launch
has to be a fresh task, since recreate() leaves the system splash lying over
the new page; and a restart shows no launch splash at all, because the
splash plugin cannot take it down again when the page hides it early.
2026-09-24 13:59:08 +02:00
Maurice b1479d939a fix(native): no overscroll stretch on Android
The UI asks for no overscroll effect with overscroll-behavior: none on html,
and Chrome honours it. The Android WebView draws the stretch on its root
scroller natively, though, and it scales the whole view, so the top bar and
the bottom dock were pulled along with the page. Found in the emulator.
2026-09-24 13:36:27 +02:00
Maurice 71913eb6e2 fix(native): remember the login by default inside the app
With the switch off the app got a session cookie, which dies with the app
process, so every time the phone unloaded TREK the user had to sign in
again. Found while running the Android build in the emulator.
2026-09-24 13:30:37 +02:00
Maurice 601ed66a38 test(mobile): the backup-code download revokes its URL after the shared delay
The mobile twin of the AccountTab test still expected the object URL to be
revoked in the same tick. Since both screens download through downloadBlob,
the revoke happens after its 100 ms grace period.
2026-09-24 13:22:49 +02:00
Maurice 9a69b092e8 feat(native): the small things that give a WebView away
- A short haptic tap on success, warning and error toasts, which is where
  every save and delete already reports. Info toasts stay silent.
- No "‹ › Done" strip above the iOS keyboard.
- No Safari preview when a link is long-pressed.
- The swipe from the left edge goes back on iOS, through the same history
  Android's back button walks.
2026-09-24 13:11:25 +02:00
Maurice be211c22ed fix(native): keep the iOS offline copy inside the app
loadSimulatedRequest went to the server URL without its trailing slash, and
Capacitor keeps a navigation in the WebView only when it starts with the
bridge's serverURL, slash included. The offline start page would have
opened in Safari instead. Also covers the platform helpers and the
hand-off API module.
2026-09-24 12:48:12 +02:00
Maurice 293b25466e feat(native): offline vector maps in the iOS app
In a browser the service worker answers MapLibre from the gl-map-offline
cache that glPrefetcher fills for trips marked for offline. The iOS app has
no service worker, so nothing read that cache there and the map went blank
without network.

A trek-offline:// protocol takes the service worker's place. Inside the iOS
app the MapLibre style URL is rewritten to it, the handler rewrites every
https URL inside the style and its TileJSON the same way, and then serves
documents network first and tiles, glyphs and sprites cache first. Outside
the iOS app nothing changes. The default OpenFreeMap basemap is covered;
Mapbox GL has no protocol hook and stays online-only in the app, as does a
custom raster template, whose opaque responses only a service worker can
store.
2026-09-24 12:47:07 +02:00
Maurice 1ba58e8b89 ci(native): build the Android and iOS shells on every change to them
Compiles the Swift side on a macOS runner so nobody needs a Mac to know it
still builds, and leaves a debug APK on every run to install on a phone.
Unsigned: signing and store uploads need secrets fork PRs do not get.
2026-09-24 12:43:06 +02:00
Maurice ae0d4e4520 feat(native): the UI knows when it runs inside the app
Everything the server's UI does differently inside the iOS and Android app
lives under src/native and is inert in a browser.

Sign-in: SSO and the passkey button go through the system browser
(signInThroughBrowser), because identity providers refuse embedded
browsers and WebAuthn needs a domain association no build can have for
every server. The browser end is the new /native-handoff page: it signs
the user in if needed and hands the session over only after an explicit
tap, so a link carrying somebody else's challenge cannot pass a session
on silently. The verifier is hashed with @noble/hashes because
crypto.subtle is missing on the plain-http servers many home networks
use. An OIDC-only install no longer bounces to the IdP inside the
WebView, and passkey management is hidden in the app.

Downloads: the WebView cannot save a blob, so downloadBlob, downloadFile
and openFile write to the app cache and open the share sheet. The eight
places that built their own download anchor now use downloadBlob, which
they should have anyway.

The rest: "Change server" on the login page and in the account settings,
the splash hides after the first frame, Android's back button walks
history before it leaves the app, the status bar follows the scheme, and
on iOS the start page is refreshed and every chunk of the build (listed
in the new asset-manifest.json) is pulled into WebKit's cache so the app
can start without network.
2026-09-24 12:42:34 +02:00
Maurice c5765bbf15 feat(native): the apps load the server's own UI
The apps ship only an address screen now. Once a server is picked, the
native side points the WebView at it: TrekBridgeViewController on iOS and a
config copy with server.url on Android. The UI then comes from that server,
in the version it runs and on its own origin, so the session cookie,
uploads, /ws and plugins work exactly as in a browser and no app release
has to keep pace with the servers out there.

The address screen (native-shell/, built into native-dist/) probes
/api/health/features through native HTTP, tries https before http when no
scheme was typed, and refuses servers without the nativeApp flag. It also
serves as the error page when the remembered server does not load: retry,
change server, or on iOS the stored copy of the server's start page.

TrekShell is the app's own plugin for all of that: remember, change and
reload the server, sign in through ASWebAuthenticationSession or a Custom
Tab, and on iOS keep and open the offline copy of the start page, because
WKWebView runs no service worker for a domain the app does not list.

Plugins the frame does not use are gone (push, camera, geolocation,
calendar, file transfer, in-app browser and more): the WebView covers the
camera and location itself, and push needs a relay before it is worth
shipping. With them go the push entitlement, the calendar usage strings
and the Firebase bits. The iOS scheme falls back to capacitor://, since
Capacitor drops an iosScheme of https, and the arm64 capability replaces
armv7.
2026-09-24 12:30:32 +02:00
Maurice 9832d12751 feat(auth): hand a browser sign-in over to the native app
The native apps show the server's own UI in a WebView, where neither SSO nor
passkeys can run. They sign in through the system browser instead and need a
way to carry that session back.

The app keeps a random verifier and sends only its sha256 into the browser.
Once signed in there, the browser trades that challenge for a one-time code
(cookie session only, so an API or MCP token cannot mint itself a browser
session), and the WebView redeems the code together with the verifier. The
code rides through a URL scheme any app could claim, so on its own it is
worthless; it is single use and good for a minute.

Also: /api/health/features reports nativeApp so the apps can refuse servers
that predate this, and hashed files under assets/ are served immutable,
which the iOS app needs to start offline without a service worker.
2026-09-24 12:19:10 +02:00
Maurice 14825df993 Merge remote-tracking branch 'origin/dev' into native-apps
# Conflicts:
#	client/package.json
#	package-lock.json
2026-09-24 11:53:50 +02:00
jubnl e12be0a185 Merge remote-tracking branch 'origin/dev' into feat/native-apps 2026-08-25 23:47:41 +02:00
jubnl 9f1f33213b chore(capacitor): base plugins 2026-08-24 00:23:51 +02:00
jubnl b2c029f02a Merge remote-tracking branch 'origin/dev' into feat/native-apps 2026-08-23 15:27:42 +02:00
jubnl 915a1f64e6 chore(capacitor): base files for capacitor 2026-08-22 22:52:25 +02:00
203 changed files with 788 additions and 3823 deletions
+1 -1
View File
@@ -56,5 +56,5 @@ See `values.yaml` for more options.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands `/app/data` and `/app/uploads` to `node` (uid 1000) if the directory or anything directly inside it is not owned by `node` yet, and drops to `node`. Deeper files are not checked. Only the owning user is changed (plus a `root` group), so a different `fsGroup` stays in place. A volume that refuses `chown` (NFS with `root_squash`) logs a warning instead; set `runAsUser: 1000` then. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands any file under `/app/data` and `/app/uploads` that is not owned by `node` yet to `node`, and drops to `node`. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
- Use `extraVolumes` and `extraVolumeMounts` to add your own volumes to the pod. To give the booking import more KItinerary extractors, mount a ConfigMap at `/usr/local/share/kitinerary/extractors`, with one `.json` and one `.js` key for each extractor; see the example in `values.yaml`.
@@ -1,104 +0,0 @@
import { expect, test, type Locator } from '@playwright/test';
// #2981: labels, category and status took the whole row, so a place with two or
// three long labels showed one letter of its name or none, and the status pill
// ran out of the card. Measured in a real browser because the layout is the bug.
const PLACES = [
{ name: 'Oceanario de Lisboa', labels: ['Core shortlist', 'Heritage specialist', 'Pair with another stop'] },
{ name: 'Cove of Ursa', labels: ['Heritage specialist', 'Pair with another stop'] },
{ name: 'A Vida Portuguesa', labels: ['Flagship', 'Market', 'Core shortlist', 'Pair with another stop'] },
{ name: 'Torre de Belem', labels: [] as string[] },
];
async function expectInside(row: Locator) {
const escapes = await row.evaluate((el) => {
const box = el.getBoundingClientRect();
return Array.from(el.querySelectorAll('*')).filter((child) => {
// The strip's hidden measuring copy is meant to run past the bar.
if (child.closest('[aria-hidden="true"]')) return false;
const r = child.getBoundingClientRect();
return r.width > 0 && (r.left < box.left - 0.5 || r.right > box.right + 0.5);
}).length;
});
expect(escapes).toBe(0);
}
for (const viewport of [
{ width: 1920, height: 1000 },
{ width: 1400, height: 900 },
{ width: 1024, height: 800 },
]) {
test(`place names stay readable next to long labels at ${viewport.width}px`, async ({ page }) => {
test.setTimeout(90_000);
await page.setViewportSize(viewport);
const addonsResponse = await page.request.get('/api/admin/addons');
expect(addonsResponse.ok()).toBeTruthy();
const { addons } = await addonsResponse.json();
const original = addons.find((addon: { id: string }) => addon.id === 'collections');
expect(original).toBeDefined();
let collectionId: number | undefined;
try {
expect((await page.request.put('/api/admin/addons/collections', { data: { enabled: true } })).ok()).toBeTruthy();
const created = await page.request.post('/api/addons/collections', { data: { name: `Rows ${viewport.width}` } });
expect(created.ok()).toBeTruthy();
const body = await created.json();
collectionId = (body.collection ?? body).id as number;
const labelIds = new Map<string, number>();
for (const name of new Set(PLACES.flatMap((p) => p.labels))) {
const res = await page.request.post('/api/addons/collections/labels', {
data: { collection_id: collectionId, name, color: '#2563eb' },
});
expect(res.ok(), `label ${name}`).toBeTruthy();
const label = await res.json();
labelIds.set(name, (label.label ?? label).id);
}
for (const place of PLACES) {
const res = await page.request.post('/api/addons/collections/places', {
data: {
collection_id: collectionId,
name: place.name,
address: 'Rua Anchieta 11, 1200-023 Lisboa, Portugal',
lat: 38.71,
lng: -9.14,
},
});
expect(res.ok(), `place ${place.name}`).toBeTruthy();
const saved = await res.json();
if (!place.labels.length) continue;
const assign = await page.request.post('/api/addons/collections/labels/assign', {
data: { label_ids: place.labels.map((l) => labelIds.get(l)), place_ids: [(saved.place ?? saved).id] },
});
expect(assign.ok()).toBeTruthy();
}
await page.goto(`/collections/${collectionId}`);
const rows = page.locator('.col-lrow');
await expect(rows).toHaveCount(PLACES.length);
for (const place of PLACES) {
const row = rows.filter({ hasText: place.name });
const name = row.locator('.li .t');
await expect(name).toHaveText(place.name);
// The whole name fits: it is not cut to an ellipsis.
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await expectInside(row);
// Every label is either on the row or counted by its "+N" chip.
const shown = await row.locator('.col-lrow-label:not(.is-over)').count();
// Read without waiting: a row whose labels all fit has no "+N".
const [more] = await row.locator('.col-lrow-more').allTextContents();
expect(shown + (more ? Number(more.replace('+', '')) : 0)).toBe(place.labels.length);
}
// The map's label strip stays inside the map: whole, or folded into its button.
const topbar = page.locator('.col-map-topbar');
if (await topbar.count()) await expectInside(topbar);
} finally {
if (collectionId !== undefined) {
expect((await page.request.delete(`/api/addons/collections/${collectionId}`)).ok()).toBeTruthy();
}
expect(
(await page.request.put('/api/admin/addons/collections', { data: { enabled: !!original.enabled } })).ok()
).toBeTruthy();
}
});
}
+3 -3
View File
@@ -143,7 +143,7 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.12",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.6.1",
"eslint": "^10.11.0",
"eslint-config-flat-gitignore": "^2.4.0",
@@ -162,8 +162,8 @@
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "8.3.5",
"vite": "8.3.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.12"
"vitest": "^4.1.11"
}
}
+3 -3
View File
@@ -8,10 +8,10 @@
"trek/no-swallowed-catch": 128,
"max-params": 32,
"trek/store-without-selector": 67,
"react-hooks/refs": 553,
"react-hooks/refs": 554,
"react-hooks/set-state-in-effect": 193,
"@typescript-eslint/no-floating-promises": 164,
"react-hooks/exhaustive-deps": 115,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
@@ -31,7 +31,7 @@
},
"suppressed": {
"@typescript-eslint/no-explicit-any": 38,
"react-hooks/exhaustive-deps": 45
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
+3 -3
View File
@@ -5,14 +5,14 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1214,
"src/components/Map/MapView.tsx": 1337,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2183,
"src/components/Map/MapViewGL.tsx": 2198,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2929,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceInspector.tsx": 1287,
"src/components/Planner/PlaceInspector.tsx": 1290,
"src/components/Studio/TravelElements.tsx": 1559,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
+1 -1
View File
@@ -38,7 +38,7 @@
"src/components/Journey/journeyGeo.ts": 4,
"src/components/Layout/Navbar.test.tsx": 1,
"src/components/Map/MapView.test.tsx": 2,
"src/components/Map/MapView.tsx": 7,
"src/components/Map/MapView.tsx": 8,
"src/components/Map/MapViewGL.tsx": 1,
"src/components/Map/ReservationOverlay.test.tsx": 1,
"src/components/Map/transitGeometry.ts": 1,
+1 -2
View File
@@ -19,9 +19,8 @@
"components/Journey/journeyPhotoLayer.ts": 2,
"components/Map/DawarichTrailLayer.tsx": 1,
"components/Map/dawarichTrail.ts": 10,
"components/Map/markerLook.ts": 1,
"components/Map/useDawarichTrailGL.ts": 1,
"components/Packing/PackingBringersCard.tsx": 1,
"components/Packing/PackingListPanelItemRow.tsx": 1,
"components/Planner/DayReorderPopup.tsx": 1,
"components/Planner/bookings/bookingsModel.ts": 1,
"components/Planner/bookings/transitParts.tsx": 1,
@@ -1,8 +1,6 @@
import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared';
import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { formatDisplayDate } from '../../../utils/dateFormat';
import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
@@ -73,9 +71,8 @@ export function AutoLoadSettings({ admin }: { admin: Admin }) {
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
const { t, language } = useTranslation();
const megabytes = useMegabytes();
const dateFormat = useDateFormat();
const busy = admin.busyId === request.id;
const date = formatDisplayDate(new Date(request.requested_at), language, { dateStyle: 'medium' }, dateFormat);
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' : ''}`}
@@ -1,9 +1,7 @@
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import { Check, ChevronRight, Loader2, Plus, RefreshCw, Search, Trash2, Waves, WifiOff } from 'lucide-react';
import { useId } from 'react';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { formatDisplayDate } from '../../../utils/dateFormat';
import {
SettingRows,
SETTINGS_BUTTON,
@@ -260,10 +258,9 @@ function RegionNetwork({ region }: { region: PaddleRegion }) {
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
const { t, language } = useTranslation();
const dateFormat = useDateFormat();
const busy = admin.busyId === region.id;
const date = region.source_timestamp
? formatDisplayDate(new Date(region.source_timestamp), language, { dateStyle: 'medium' }, dateFormat)
? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' })
: null;
return (
<div
@@ -1,4 +1,4 @@
// FE-ADMIN-STOR-RECLAIM-001 to -009: the reclaim report as both storage panels show it.
// FE-ADMIN-STOR-RECLAIM-001 to -007: the reclaim report as both storage panels show it.
import { STORAGE_RECLAIM_CATEGORIES, type StorageReclaimCategoryReport, type StorageReclaimReport } from '@trek/shared';
import { reclaimView } from './reclaimModel';
@@ -137,84 +137,4 @@ describe('reclaimView', () => {
it('FE-ADMIN-STOR-RECLAIM-007: a running check reads as running', () => {
expect(reclaimView({ deleteEnabled: false, running: true, lastRun: null, lastDeleteRun: null }).running).toBe(true);
});
it('FE-ADMIN-STOR-RECLAIM-008: a category held back by a backup or a failed delete still counts what was judged and not deleted', () => {
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: null,
lastRun: report(
{
covers: category({
skipped: 'backup_running',
unreferenced: { objects: 3_000, bytes: 3_000 },
eligible: { objects: 3_000, bytes: 3_000 },
}),
places: category({
skipped: 'backup_running',
unreferenced: { objects: 1_500, bytes: 150 },
eligible: { objects: 1_200, bytes: 120 },
deleted: { objects: 1_000, bytes: 100 },
}),
files: category({
skipped: 'delete_failed',
unreferenced: { objects: 5, bytes: 50 },
eligible: { objects: 4, bytes: 40 },
deleted: { objects: 1, bytes: 10 },
}),
},
'delete'
),
});
const byCategory = Object.fromEntries(view.lines.map((line) => [line.category, line]));
expect(byCategory.covers).toMatchObject({
freeable: { objects: 3_000, bytes: 3_000 },
waiting: none,
skipped: 'backup_running',
});
expect(byCategory.places).toMatchObject({
freeable: { objects: 200, bytes: 20 },
waiting: { objects: 300, bytes: 30 },
skipped: 'backup_running',
});
expect(byCategory.files).toMatchObject({
freeable: { objects: 3, bytes: 30 },
waiting: { objects: 1, bytes: 10 },
skipped: 'delete_failed',
});
expect(view.freeable).toEqual({ objects: 3_203, bytes: 3_050 });
expect(view.waiting).toEqual({ objects: 301, bytes: 40 });
});
it('FE-ADMIN-STOR-RECLAIM-009: a category whose sources or listing failed shows its reason and counts nothing', () => {
const view = reclaimView({
deleteEnabled: true,
running: false,
lastDeleteRun: null,
lastRun: report({
covers: category({ skipped: 'source_failed', objects: 12 }),
files: category({ skipped: 'list_failed' }),
}),
});
expect(view.lines).toEqual([
{
category: 'files',
freeable: none,
waiting: none,
confirmationRequired: false,
unreferenced: 0,
skipped: 'list_failed',
},
{
category: 'covers',
freeable: none,
waiting: none,
confirmationRequired: false,
unreferenced: 0,
skipped: 'source_failed',
},
]);
expect(view.freeable).toEqual(none);
expect(view.waiting).toEqual(none);
});
});
@@ -67,23 +67,11 @@ function freedBy(run: StorageReclaimReport | null): ReclaimCount {
);
}
type SkipReason = StorageReclaimCategoryReport['skipped'];
/**
* The reasons the pass gives when it left a category before judging it, so its
* counts mean nothing. Every other reason (a backup running, a failed delete,
* an interrupted pass) comes after the judgement and keeps its figures; a new
* reason counts by default rather than hiding what the pass found.
* The reclaim state as the storage panels show it, desktop and phone alike.
* `null` (a server from before the reclaim pass, or a state not loaded) reads
* as deletion off, not running and never checked.
*/
const NEVER_JUDGED: ReadonlySet<SkipReason> = new Set<SkipReason>([
'sources_missing',
'shared_location',
'foreign_install',
'owner_unknown',
'list_failed',
'source_failed',
]);
/**
* One category of the last check as a line, or null when it has nothing to
* say. A category the pass stopped in still counts what it found; one it
@@ -94,7 +82,7 @@ function lineOf(
report: StorageReclaimCategoryReport | undefined
): ReclaimCategoryLine | null {
if (!report || report.skipped === 'not_reclaimed') return null;
const judged = !NEVER_JUDGED.has(report.skipped);
const judged = report.skipped === null || report.skipped === 'interrupted' || report.skipped === 'delete_failed';
// What was deleted on this run no longer waits to be freed.
const freeable = judged ? minus(report.eligible, report.deleted) : NONE;
const waiting = judged ? minus(report.unreferenced, report.eligible) : NONE;
@@ -35,7 +35,7 @@ function Dropdown({ current, options, onSelect, lead }: {
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="col-filter" ref={ref}>
<button type="button" className={`col-filter-btn${open ? ' open' : ''}${cur.key === options[0]?.key ? ' is-default' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open} title={cur.label}>
<button type="button" className={`col-filter-btn${open ? ' open' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}>
{cur.icon ?? lead}
<span className="col-filter-lbl">{cur.label}</span>
<ChevronDown size={14} className="col-filter-chev" />
@@ -1,121 +0,0 @@
// FE-COMP-COLLABELS-001 to FE-COMP-COLLABELS-006
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import type { TranslationFn } from '../../types';
import CollectionLabelFilter, { type LabelOption } from './CollectionLabelFilter';
const t = ((key: string) => key) as unknown as TranslationFn;
const LABELS = [
{ id: 1, name: 'Core shortlist', color: '#2563eb', count: 2 },
{ id: 2, name: 'Heritage', color: null, count: 0 },
] as unknown as LabelOption[];
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(() => vi.restoreAllMocks());
/** The labels need `need` px against a slot of `room` px beside the map's controls. */
function room(need: number, room: number) {
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) {
return this.classList.contains('col-labelfilter-measure') ? need : 0;
});
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function (this: Element) {
return this.classList.contains('col-labelfilter-slot') ? room : 0;
});
}
function setup(props: Partial<Parameters<typeof CollectionLabelFilter>[0]> = {}) {
const onLabelFilter = vi.fn();
const onManageLabels = vi.fn();
const view = render(
<CollectionLabelFilter
labelOptions={LABELS}
labelFilter={[]}
onLabelFilter={onLabelFilter}
canManageLabels
onManageLabels={onManageLabels}
t={t}
{...props}
/>
);
return { ...view, onLabelFilter, onManageLabels };
}
describe('CollectionLabelFilter', () => {
it('FE-COMP-COLLABELS-001: the filter row toggles a label on and off', () => {
const on = setup();
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
expect(on.onLabelFilter).toHaveBeenCalledWith([1]);
on.unmount();
const off = setup({ labelFilter: [1, 2] });
expect(screen.getByRole('button', { name: /Core shortlist/ })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
expect(off.onLabelFilter).toHaveBeenCalledWith([2]);
});
it('FE-COMP-COLLABELS-002: offers to add a label when there is none, and to manage them otherwise', () => {
const empty = setup({ labelOptions: [] });
fireEvent.click(screen.getByRole('button', { name: 'collections.labels.add' }));
expect(empty.onManageLabels).toHaveBeenCalled();
empty.unmount();
setup();
expect(screen.getByRole('button', { name: 'collections.labels.manage' })).toBeInTheDocument();
});
it('FE-COMP-COLLABELS-003: on the map the strip shows its labels while they fit', () => {
room(300, 500);
setup({ variant: 'map' });
expect(screen.getByRole('button', { name: /Heritage/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /collections.labels.title/ })).toBeNull();
});
it('FE-COMP-COLLABELS-004: without room it folds into one button that lists the labels', () => {
room(600, 200);
const { onLabelFilter } = setup({ variant: 'map', labelFilter: [2] });
const fold = screen.getByRole('button', { name: /collections.labels.title/ });
expect(fold).toHaveTextContent('1');
expect(fold).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(fold);
const list = screen.getByRole('group', { name: 'collections.labels.title' });
expect(list).toHaveTextContent('Core shortlist');
expect(screen.getByRole('button', { name: /Heritage/ })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
expect(onLabelFilter).toHaveBeenCalledWith([2, 1]);
});
it('FE-COMP-COLLABELS-005: the folded list manages labels and closes on Escape or a click beside it', () => {
room(600, 200);
const { onManageLabels } = setup({ variant: 'map' });
const fold = screen.getByRole('button', { name: /collections.labels.title/ });
fireEvent.click(fold);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
fireEvent.click(fold);
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
fireEvent.click(fold);
fireEvent.click(screen.getByRole('button', { name: /collections.labels.manage/ }));
expect(onManageLabels).toHaveBeenCalled();
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
});
it('FE-COMP-COLLABELS-006: a click inside the folded list keeps it open', () => {
room(600, 200);
setup({ variant: 'map' });
fireEvent.click(screen.getByRole('button', { name: /collections.labels.title/ }));
fireEvent.mouseDown(screen.getByRole('group', { name: 'collections.labels.title' }));
expect(screen.getByRole('group', { name: 'collections.labels.title' })).toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Check, ChevronDown, Plus, Settings2, Tags } from 'lucide-react'
import React from 'react'
import { Plus, Settings2, Tags } from 'lucide-react'
import type { LabelOption } from '../../pages/collections/collectionsModel'
import type { TranslationFn } from '../../types'
@@ -19,154 +19,54 @@ interface CollectionLabelFilterProps {
t: TranslationFn
}
interface ChipsProps {
labelOptions: LabelOption[]
labelFilter: number[]
onToggle?: (id: number) => void
}
/** The label chips, as buttons, or as plain spans for the copy the map strip measures. */
function LabelChips({ labelOptions, labelFilter, onToggle }: ChipsProps): React.ReactElement {
return (
<>
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
const body = (
<>
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
</>
)
const style = { ['--label' as string]: l.color ?? 'var(--accent)' }
if (!onToggle) return <span key={l.id} className={`col-labelchip${on ? ' on' : ''}`} style={style}>{body}</span>
return (
<button key={l.id} type="button" className={`col-labelchip${on ? ' on' : ''}`} style={style} onClick={() => onToggle(l.id)} aria-pressed={on}>
{body}
</button>
)
})}
</>
)
}
/**
* Whether the map strip has to fold into its button: true once its chips need more room
* than the slot beside the map's other controls. Measured on a hidden copy, so the strip
* unfolds again as soon as the room comes back.
*/
function useFolds(enabled: boolean) {
const slotRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const [folded, setFolded] = useState(false)
useLayoutEffect(() => {
const slot = slotRef.current
const measure = measureRef.current
if (!enabled || !slot || !measure) return
const check = () => setFolded(measure.scrollWidth > slot.clientWidth + 1)
check()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(check)
observer.observe(slot)
observer.observe(measure)
return () => observer.disconnect()
}, [enabled])
return { slotRef, measureRef, folded }
}
/** Open state of the folded strip's list: closed by a click beside it or Escape, like the filter row's dropdowns. */
function useFoldList(ref: React.RefObject<HTMLElement | null>) {
const [open, setOpen] = useState(false)
useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey) }
}, [open, ref])
return { open, setOpen }
}
/**
* The label filter, in one place because it has two homes.
*
* Labels are an axis of their own next to status, category and rating, so they
* are grouped into a tray instead of joining the row as more loose buttons. On
* a desktop it rides in the map's top bar; when the bar has no room for every
* label it folds into one button that opens them as a list, instead of hiding
* the last ones behind a scroll nobody sees. The filter row keeps it whenever
* no map is on screen, so it never becomes unreachable.
* a desktop it rides in the map's top bar, where there is room to spare; the
* filter row keeps it whenever no map is on screen, so it never becomes
* unreachable.
*/
export default function CollectionLabelFilter({
labelOptions, labelFilter, onLabelFilter, canManageLabels = false, onManageLabels,
variant = 'bar', t,
}: CollectionLabelFilterProps): React.ReactElement {
const onMap = variant === 'map'
const { slotRef, measureRef, folded } = useFolds(onMap)
const fold = useFoldList(slotRef)
const toggle = (id: number) => onLabelFilter(labelFilter.includes(id) ? labelFilter.filter(x => x !== id) : [...labelFilter, id])
const manageLabel = labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')
const manage = canManageLabels && onManageLabels && (
<button type="button" className="col-filter-addlabel" onClick={onManageLabels} title={manageLabel} aria-label={manageLabel}>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
</button>
)
const tray = (
<div className={`col-labelfilter${onMap ? ' on-map' : ''}`} role="group" aria-label={t('collections.labels.manage')}>
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
<LabelChips labelOptions={labelOptions} labelFilter={labelFilter} onToggle={toggle} />
{manage}
</div>
)
if (!onMap) return tray
const active = labelFilter.filter(id => labelOptions.some(l => l.id === id)).length
return (
<div className="col-labelfilter-slot" ref={slotRef}>
{!folded ? tray : (
<>
<div
className={`col-labelfilter${variant === 'map' ? ' on-map' : ''}`}
role="group"
aria-label={t('collections.labels.manage')}
>
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<button
key={l.id}
type="button"
onClick={() => fold.setOpen(o => !o)}
aria-haspopup="true"
aria-expanded={fold.open}
className={`col-labelfilter on-map col-labelfilter-fold${fold.open ? ' open' : ''}`}
className={`col-labelchip${on ? ' on' : ''}`}
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
aria-pressed={on}
>
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
<span className="col-filter-lbl">{t('collections.labels.title')}</span>
{active > 0 && <span className="col-labelfilter-active">{active}</span>}
<ChevronDown size={14} className="col-filter-chev" aria-hidden="true" />
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
</button>
{fold.open && (
<div className="col-filter-pop col-labelfilter-pop" role="group" aria-label={t('collections.labels.title')}>
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<button key={l.id} type="button" aria-pressed={on} className={`col-filter-opt${on ? ' on' : ''}`} onClick={() => toggle(l.id)}>
<span className="col-filter-dot" style={{ background: l.color ?? 'var(--accent)' }} />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
{on && <Check size={13} className="col-filter-check" />}
</button>
)
})}
{canManageLabels && onManageLabels && (
<button type="button" className="col-filter-opt col-labelfilter-manage" onClick={() => { fold.setOpen(false); onManageLabels() }}>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
<span className="col-filter-lbl">{manageLabel}</span>
</button>
)}
</div>
)}
</>
)
})}
{canManageLabels && onManageLabels && (
<button
type="button"
className="col-filter-addlabel"
onClick={onManageLabels}
title={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
aria-label={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
</button>
)}
<div className="col-labelfilter on-map col-labelfilter-measure" ref={measureRef} aria-hidden="true">
<Tags size={13} className="col-labelfilter-lead" />
<LabelChips labelOptions={labelOptions} labelFilter={labelFilter} />
{canManageLabels && onManageLabels && <span className="col-filter-addlabel"><Settings2 size={13} /></span>}
</div>
</div>
)
}
@@ -3,8 +3,8 @@ import { Check, MapPin } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
import type { TranslationFn } from '../../types'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import StatusBadge from './StatusBadge'
import CollectionPlaceTags from './CollectionPlaceTags'
interface CollectionListProps {
places: CollectionPlace[]
@@ -51,7 +51,7 @@ export default function CollectionList({
tabIndex={0}
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (selectMode) onToggleSelect(place.id); else onOpenPlace(place.id) } }}
className={`col-lrow col-lrow-place${active || selected ? ' sel' : ''}`}
className={`col-lrow${active || selected ? ' sel' : ''}`}
>
{selectMode ? (
<span className={`col-lcheck${selected ? ' on' : ''}`}>{selected && <Check size={14} strokeWidth={3} />}</span>
@@ -67,8 +67,24 @@ export default function CollectionList({
</div>
)}
</div>
<CollectionPlaceTags place={place} labels={placeLabels} />
<div className="col-lrow-status">
<div className="col-lrow-end">
{placeLabels.slice(0, 2).map(l => (
<span key={l.id} className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }} title={l.name}>
<span className="col-labelchip-dot" /> {l.name}
</span>
))}
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
{place.category?.name && (() => {
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
return (
<>
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={11} /> {place.category.name}
</span>
<span className="col-lrow-div" aria-hidden />
</>
)
})()}
<StatusBadge status={place.status} onChange={selectMode || !onStatusChange ? undefined : next => onStatusChange(place.id, next)} t={t} />
</div>
</div>
@@ -1,88 +0,0 @@
// FE-COMP-COLTAGS-001 to FE-COMP-COLTAGS-005
import type { CollectionLabel, CollectionPlace } from '@trek/shared';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import CollectionPlaceTags from './CollectionPlaceTags';
// jsdom lays nothing out: the observer reports each element once it is observed, and
// the widths below stand in for the browser's.
beforeAll(() => {
globalThis.ResizeObserver = class {
constructor(private readonly cb: ResizeObserverCallback) {}
observe(target: Element) {
this.cb([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(() => vi.restoreAllMocks());
/** Row and chip widths in px: the row holds the category, one label and the "+N" chip. */
function layout({ row = 250, cutName }: { row?: number; cutName?: string } = {}) {
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
if (this.classList.contains('col-lrow-cat')) return 50;
if (this.classList.contains('col-lrow-label')) return 100;
return 0;
});
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function (this: Element) {
if (this.classList.contains('col-lrow-tags')) return row;
if (this.classList.contains('col-lrow-txt')) return 60;
return 0;
});
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) {
if (this.classList.contains('col-lrow-txt')) return this.textContent === cutName ? 90 : 60;
return 0;
});
}
const label = (id: number, name: string): CollectionLabel =>
({ id, collection_id: 1, name, color: '#2563eb' }) as unknown as CollectionLabel;
const place = (category: { name: string; color?: string; icon?: string } | null = { name: 'Attraction' }) =>
({ id: 7, name: 'Torre', category }) as unknown as CollectionPlace;
const LABELS = [label(1, 'Core shortlist'), label(2, 'Heritage'), label(3, 'Pair with another stop')];
describe('CollectionPlaceTags', () => {
it('FE-COMP-COLTAGS-001: draws nothing for a place without labels or a category', () => {
const { container } = render(<CollectionPlaceTags place={place(null)} labels={[]} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-COMP-COLTAGS-002: shows the category first, then the labels that fit, then a "+N" for the rest', () => {
layout();
const { container } = render(<CollectionPlaceTags place={place()} labels={LABELS} />);
const chips = Array.from(container.querySelectorAll('.col-lrow-tags > span')).map((el) => el.className);
expect(chips[0]).toBe('col-lrow-cat');
expect(container.querySelectorAll('.col-lrow-label:not(.is-over)')).toHaveLength(1);
expect(container.querySelectorAll('.col-lrow-label.is-over')).toHaveLength(2);
expect(container.querySelector('.col-lrow-label.is-over')).toHaveAttribute('aria-hidden', 'true');
expect(screen.getByText('+2')).toBeInTheDocument();
});
it('FE-COMP-COLTAGS-003: a row wide enough shows every label and no "+N"', () => {
layout({ row: 800 });
const { container } = render(<CollectionPlaceTags place={place()} labels={LABELS} />);
expect(container.querySelectorAll('.col-lrow-label:not(.is-over)')).toHaveLength(3);
expect(container.querySelector('.col-lrow-more')).toBeNull();
});
it('FE-COMP-COLTAGS-004: the "+N" names the hidden labels in its tooltip', async () => {
layout();
render(<CollectionPlaceTags place={place()} labels={LABELS} />);
fireEvent.mouseEnter(screen.getByText('+2'));
expect(await screen.findByRole('tooltip')).toHaveTextContent('Heritage, Pair with another stop');
});
it('FE-COMP-COLTAGS-005: a chip says its name in a tooltip only while it cuts the name short', async () => {
layout({ row: 800, cutName: 'Heritage' });
render(<CollectionPlaceTags place={place()} labels={LABELS} />);
fireEvent.mouseEnter(screen.getByText('Core shortlist').closest('.col-lrow-label') as HTMLElement);
await new Promise((r) => setTimeout(r, 350));
expect(screen.queryByRole('tooltip')).toBeNull();
fireEvent.mouseLeave(screen.getByText('Core shortlist').closest('.col-lrow-label') as HTMLElement);
fireEvent.mouseEnter(screen.getByText('Heritage').closest('.col-lrow-label') as HTMLElement);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Heritage');
});
});
@@ -1,145 +0,0 @@
import type { CollectionLabel, CollectionPlace } from '@trek/shared';
import React, { createElement, useEffect, useRef, useState } from 'react';
import { getCategoryIcon } from '../shared/categoryIcons';
import { Tooltip } from '../shared/Tooltip';
/**
* One observer for every row and chip of a list, so a list of hundreds of places does
* not hold one per element. An element starts with one notification once observed.
*/
const sizeWatch = new Map<Element, () => void>();
let sizeObserver: ResizeObserver | null = null;
function watchSize(el: Element, onResize: () => void): () => void {
if (typeof ResizeObserver === 'undefined') return () => {};
sizeObserver ??= new ResizeObserver((entries) => {
for (const entry of entries) sizeWatch.get(entry.target)?.();
});
sizeWatch.set(el, onResize);
sizeObserver.observe(el);
return () => {
sizeWatch.delete(el);
sizeObserver?.unobserve(el);
};
}
/**
* The tooltip of a label or category chip: only while the chip cuts its name short,
* since one repeating the words on screen says nothing.
*/
function ChipTooltip({
label,
children,
}: {
label: string;
children: React.ReactElement<React.HTMLAttributes<HTMLElement> & React.RefAttributes<HTMLElement>>;
}): React.ReactElement {
const [cut, setCut] = useState(false);
const ref = useRef<HTMLElement | null>(null);
useEffect(() => {
const text = ref.current?.querySelector('.col-lrow-txt');
if (!text) return undefined;
return watchSize(text, () => setCut(text.scrollWidth > text.clientWidth + 1));
}, []);
return (
<Tooltip label={label} placement="top" disabled={!cut}>
{React.cloneElement(children, { ref })}
</Tooltip>
);
}
/** The room a "+N" chip takes, held free while the labels are fitted. */
const MORE_CHIP = 34;
/**
* A chip's own width, also while the row squeezes it: the part its name is cut short by
* is the name's scroll width over its box, up to the widest the chip may grow.
*/
const naturalWidth = (el: HTMLElement): number => {
const text = el.querySelector<HTMLElement>('.col-lrow-txt');
const full = el.offsetWidth + (text ? text.scrollWidth - text.clientWidth : 0);
const cap = parseFloat(getComputedStyle(el).maxWidth);
return Number.isFinite(cap) ? Math.min(full, cap) : full;
};
/**
* How many labels fit beside the category in `row`: as many as it holds with room left
* for the "+N" chip, and at least one, which then shortens its name. Every label is in
* the row, the ones that do not fit kept out of sight, so each can be measured.
*/
function labelsThatFit(row: HTMLElement): number {
const gap = parseFloat(getComputedStyle(row).columnGap) || 0;
const labels = Array.from(row.querySelectorAll<HTMLElement>('.col-lrow-label'));
const fixed = Array.from(row.querySelectorAll<HTMLElement>('.col-lrow-cat, .col-lrow-div'))
.filter((el) => el.offsetWidth > 0)
.reduce((sum, el) => sum + naturalWidth(el) + gap, 0);
let used = fixed;
let fit = 0;
for (let i = 0; i < labels.length; i++) {
const left = labels.length - i - 1;
const need = used + naturalWidth(labels[i]) + (left > 0 ? gap + MORE_CHIP : 0);
if (need > row.clientWidth && i > 0) break;
used += naturalWidth(labels[i]) + gap;
fit = i + 1;
}
return fit;
}
/**
* A place row's category and labels, on one line that never wraps (#2981). The category
* comes first, then as many labels as the row has room for, then a "+N" chip naming the
* rest in its tooltip, so a wide list shows every label and a narrow one the first.
*/
export default function CollectionPlaceTags({
place,
labels,
}: {
place: CollectionPlace;
labels: CollectionLabel[];
}): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(null);
const [fit, setFit] = useState(labels.length);
const labelKey = labels.map((l) => `${l.id}:${l.name}`).join('|');
const categoryName = place.category?.name ?? null;
// Fitted on every change of the row's width and once more whenever its labels change:
// observing anew hands the observer's first notification to the new labels.
useEffect(() => {
const row = ref.current;
if (!row) return undefined;
return watchSize(row, () => setFit(labelsThatFit(row)));
}, [labelKey, categoryName]);
if (!labels.length && !categoryName) return null;
const shown = Math.min(labels.length, Math.max(1, fit));
const hidden = labels.slice(shown);
return (
<div ref={ref} className="col-lrow-tags">
{categoryName && (
<ChipTooltip label={categoryName}>
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category?.color || '#6366f1' }}>
{createElement(getCategoryIcon(place.category?.icon ?? undefined), { size: 11 })}{' '}
<span className="col-lrow-txt">{categoryName}</span>
</span>
</ChipTooltip>
)}
{labels.map((l, i) => (
<ChipTooltip key={l.id} label={l.name}>
<span
className={i < shown ? 'col-lrow-label' : 'col-lrow-label is-over'}
style={{ ['--label' as string]: l.color || 'var(--accent)' }}
aria-hidden={i < shown ? undefined : true}
>
<span className="col-labelchip-dot" /> <span className="col-lrow-txt">{l.name}</span>
</span>
</ChipTooltip>
))}
{hidden.length > 0 && (
<Tooltip label={hidden.map((l) => l.name).join(', ')} placement="top">
<span className="col-lrow-more">+{hidden.length}</span>
</Tooltip>
)}
<span className="col-lrow-div" aria-hidden />
</div>
);
}
@@ -1,8 +1,6 @@
import { Clock, FilePen, Loader2, RefreshCw, Route, Users } from 'lucide-react';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDisplayDateTime } from '../../utils/dateFormat';
import { formatClockTime } from '../../utils/formatters';
import { formatDistance } from '../../utils/units';
import { SoftPill } from '../Planner/planParts';
@@ -28,7 +26,6 @@ type Props = { state: JourneyDayImportState; dayColor: string; prep?: JourneyRec
*/
function DayNotices({ state, prep }: { state: JourneyDayImportState; prep?: JourneyRecordingPrepState }) {
const { t, locale } = useTranslation();
const dateFormat = useDateFormat();
const { review, outcome } = state;
const reason = review ? reasonText(review.error, t) : '';
const day = prep?.dayPrep(state.date);
@@ -46,12 +43,7 @@ function DayNotices({ state, prep }: { state: JourneyDayImportState; prep?: Jour
{review.syncedAt
? t('journey.dayImport.stale', {
reason,
date: formatDisplayDateTime(
new Date(review.syncedAt),
locale,
{ dateStyle: 'medium', timeStyle: 'short' },
dateFormat
),
date: new Date(review.syncedAt).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }),
})
: t('journey.dayImport.staleNever', { reason })}
</Notice>
+5 -5
View File
@@ -4,14 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { frameLng, type LngFrame } from '../../utils/lngFrame'
import { useLngFrame } from './lngFrameContext'
import { frameLng } from '../../utils/lngFrame'
export default function ClusteredPois({ pois, frame, enabled, onPoiClick, children }: {
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
/** The frame the markers in `children` are drawn in: the view's, not the trip's (see MapView). */
frame: LngFrame | null
}) {
const map = useMap()
const frame = useLngFrame()
const { t } = useTranslation()
const [zoom, setZoom] = useState(() => map.getZoom())
const popup = useRef<L.Popup | null>(null)
@@ -21,7 +21,7 @@ export default function ClusteredPois({ pois, frame, enabled, onPoiClick, childr
map.on('zoomend', update)
return () => { map.off('zoomend', update) }
}, [map])
// Measured and placed where the markers are drawn, on the world copy in view (#2975).
// Measured and placed where the map draws them, in its longitude frame (#2975).
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, frameLng(frame, poi.lng)]), zoom) : [], [pois, enabled, map, zoom, frame])
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
if (!enabled) return <>{children}</>
@@ -43,7 +43,6 @@ import { MapView } from './MapView'
import { ContextMenu } from '../shared/ContextMenu'
import type { Ferrata } from '@trek/shared'
import { groupFerratas } from '../Ferratas/ferrataGroups'
import type { Poi } from './poiCategories'
// Every map this file builds, newest last, for the tests that reach past the component.
const maps: L.Map[] = []
@@ -484,30 +483,4 @@ describe('MapView on a real Leaflet map', () => {
expect(view.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'true')
expect(view.getByTestId('ferrata-count-badge')).toHaveTextContent('1')
})
it('draws POIs found outside the trip frame where the user is looking, not one world away (#2975)', async () => {
// Seattle and Los Angeles to Auckland: the trip is drawn in a frame seamed near 28 degrees east.
const pacific = [
{ ...buildPlace({ id: 11, name: 'Seattle', lat: 47.6, lng: -122.3 }), category_name: null, category_color: null, category_icon: null },
{ ...buildPlace({ id: 12, name: 'Los Angeles', lat: 34.05, lng: -118.2 }), category_name: null, category_color: null, category_icon: null },
{ ...buildPlace({ id: 13, name: 'Auckland', lat: -36.85, lng: 174.8 }), category_name: null, category_color: null, category_icon: null },
]
const acropolis = {
osm_id: 'node/1', name: 'Acropolis', lat: 37.97, lng: 23.73, category: 'sights', poi_type: 'attraction',
address: null, website: null, phone: null, opening_hours: null, cuisine: null,
} as Poi
render(<MapView places={pacific} center={[38, 10]} zoom={5} tileUrl={RASTER} pois={[acropolis]} />)
const map = maps[maps.length - 1]
// The user pans west past the seam to look at the Mediterranean on the real world.
act(() => { map.setView([38, 10], 5, { animate: false }) })
const marker = () => {
let found: L.Marker | null = null
map.eachLayer(layer => { if (layer instanceof L.Marker && layer.options.alt === 'Acropolis') found = layer })
return found as L.Marker | null
}
await waitFor(() => expect(marker()?.getLatLng().lng).toBeCloseTo(23.73, 6))
const drawnMarker = marker()
expect(drawnMarker).not.toBeNull()
expect(drawnMarker && map.getBounds().contains(drawnMarker.getLatLng())).toBe(true)
})
})
+2 -46
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import { act, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -85,7 +85,7 @@ const clusterMock = vi.hoisted(() => {
group: {
getVisibleParent(marker: unknown) {
clusterMock.asked.push(marker)
return clusterMock.stacked ? { spiderfy, getChildCount: () => 2 } : marker
return clusterMock.stacked ? { spiderfy } : marker
},
},
}
@@ -1283,50 +1283,6 @@ describe('MapView selection panning (#921)', () => {
}
})
describe('a place card that grows over the selected pin (#2991)', () => {
const projectable = mapMock as unknown as Record<string, unknown>
// The flat projection of the mock puts lat 0.7 / lng 0.5 at (500, 700) in a 1000x1200 map.
const covered = buildMapPlace({ id: 21, lat: 0.7, lng: 0.5 })
const clear = buildMapPlace({ id: 22, lat: 0.2, lng: 0.5 })
beforeEach(() => {
projectable.project = vi.fn(() => ({ add: vi.fn(() => ({ x: 0, y: 0 })) }))
projectable.unproject = vi.fn(() => [1, 1])
projectable.getSize = vi.fn(() => ({ x: 1000, y: 1200 }))
})
afterEach(() => {
delete projectable.project
delete projectable.unproject
delete projectable.getSize
})
it('FE-COMP-MAPVIEW-090: pans the pin back out once the card measured taller than the allowance', () => {
const places = [covered]
const { rerender } = render(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={21} hasInspector />)
mapMock.panTo.mockClear()
rerender(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={21} hasInspector inspectorHeight={820} />)
expect(mapMock.panTo).toHaveBeenCalledTimes(1)
})
it('FE-COMP-MAPVIEW-091: leaves a pin that is still in the open alone', () => {
const places = [clear]
const { rerender } = render(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={22} hasInspector />)
mapMock.panTo.mockClear()
rerender(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={22} hasInspector inspectorHeight={820} />)
expect(mapMock.panTo).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-092: pans only once when the selection and the card arrive together', () => {
const places = [covered]
const { rerender } = render(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={null} />)
mapMock.panTo.mockClear()
rerender(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={21} hasInspector inspectorHeight={820} />)
expect(mapMock.panTo).toHaveBeenCalledTimes(1)
})
})
it('FE-COMP-MAPVIEW-052: falls back to a plain pan when the map cannot project', () => {
const places = [buildMapPlace({ id: 9, lat: 48, lng: 2 })]
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
+134 -13
View File
@@ -3,7 +3,7 @@ import DOM from 'react-dom'
import { categoryIconSvg } from './categoryIconSvg'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable } from './markerDrag'
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker'
@@ -49,17 +49,17 @@ import { PluginMapLayers } from './MapPluginLayers'
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { placeMarkerFrame, placeMarkerLook } from './markerLook'
import { bottomChromePadding } from './mapChromePadding'
import { placeMarkerLook } from './markerLook'
import { escapeHtml } from '@trek/shared'
import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import NightPauseTooltip from './NightPauseTooltip'
import PoiMarkers from './PoiMarkers'
import ClusteredPois from './ClusteredPois'
import { NightPauseDrag } from './NightPauseDrag'
import type { DayBoundaryControls } from './dayBoundaryDrag'
import type { Poi } from './poiCategories'
import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { SelectedTrackLine, TrackLines } from './TrackLines'
import DawarichTrailLayer from './DawarichTrailLayer'
@@ -153,7 +153,10 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
const { size, borderWidth } = look
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
// escaping stops the attribute breakout but still permits a CSS url().
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(isSelected, safeHexColor(place.category_color, 'white'))
const borderColor = isSelected ? '#111827' : safeHexColor(place.category_color, 'white')
const shadow = isSelected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
const bgColor = safeHexColor(place.category_color, '#6b7280')
// Number badges (bottom-right), or the rating where there are none: a numbered stop
@@ -185,7 +188,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
width:${size}px;height:${size}px;
cursor:pointer;position:relative;
">
<div class="${frameClass}" style="
<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
@@ -205,7 +208,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
const fallbackIcon = L.divIcon({
className: '',
html: `<div class="${frameClass}" style="
html: `<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
@@ -225,6 +228,59 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
return fallbackIcon
}
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
// markers of planned places; the colour matches its pill category.
const poiIconCache = new Map<string, L.DivIcon>()
function createPoiIcon(poi: Pick<Poi, 'category' | 'icon' | 'color'>, brandWikidata?: string | null) {
// One flat disc in the category's colour with its icon, and never the chain's logo.
// The brands turned a corridor full of petrol stations into a row of advertisements,
// they were unreadable at pin size, and a brand with no logo on file fell back to a
// different picture entirely — so no two pins looked alike. `brandWikidata` is kept in
// the signature because the callers still have it; it simply no longer changes anything.
void brandWikidata
// A plugin category's key carries its colour and icon too, so two looks never share a pin.
const cacheKey = poiPinCacheKey(poi)
const cached = poiIconCache.get(cacheKey)
if (cached) return cached
const { color, svg } = poiPinParts(poi)
const icon = L.divIcon({
className: '',
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}</div>`,
iconSize: [26, 26],
iconAnchor: [13, 13],
tooltipAnchor: [0, -14],
})
poiIconCache.set(cacheKey, icon)
return icon
}
/**
* The hover tooltip of an explore POI: its name, and for a plugin POI the rows the
* plugin knows about it (the GL hover card shows the same, see buildPoiPopupHtml).
* React text throughout, so nothing a plugin sends is ever read as markup. A POI
* without rows keeps the bare name it has always had.
*/
function PoiTooltipBody({ poi }: Readonly<{ poi: Poi }>) {
const rows = poiDetailRows(poi)
if (!rows.length) return poi.name
// w-max because Leaflet's tooltip pane is 0px wide: without an intrinsic width the
// tooltip shrinks to its narrowest layout and the values wrap a letter per line. The
// label column stops at 45% and wraps, so a long label leaves its value room too.
return (
<div className="w-max max-w-56 whitespace-normal">
<div className="truncate font-semibold">{poi.name}</div>
<div className="mt-1 grid grid-cols-[fit-content(45%)_1fr] gap-x-1.5 font-normal" data-testid="poi-details">
{rows.map(row => (
<div key={poiDetailRowKey(row)} className="contents">
<span className="text-content-muted [overflow-wrap:anywhere]">{row.label}</span>
<span className="[overflow-wrap:anywhere]">{row.value}</span>
</div>
))}
</div>
</div>
)
}
// Clears the hover tooltip the moment the camera starts moving and suppresses
// re-showing it until the move ends: after a click-recenter the marker slides
// away under a stationary cursor, so the browser never fires mouseout — and
@@ -257,6 +313,46 @@ function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number],
return null
}
interface SelectionControllerProps {
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
follow: boolean
places: Place[]
selectedPlaceId: number | null
dayPlaces: Place[]
selectedPlace: Place | null
paddingOpts: L.FitBoundsOptions
}
function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const frame = useLngFrame()
const prev = useRef(null)
useEffect(() => {
if (follow && selectedPlaceId && selectedPlaceId !== prev.current) {
// Pan to the selected place without changing zoom. Offset the centre by the
// side-panel + bottom-inspector padding so the pin lands in the middle of the
// *visible* map area rather than the geometric centre (where the bottom panel
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (selected?.lat != null && selected?.lng != null) {
const latlng: [number, number] = [selected.lat, frameLng(frame, selected.lng)]
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
const point = map.project(latlng).add([(br[0] - tl[0]) / 2, (br[1] - tl[1]) / 2])
map.panTo(map.unproject(point), { animate: true })
} else {
map.panTo(latlng, { animate: true })
}
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow, frame])
return null
}
interface MapControllerProps {
center: [number, number]
zoom: number
@@ -425,7 +521,7 @@ import { useIsPhone } from '../../mobile/useIsPhone'
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
// identically. Heading is shown as a rotated conic SVG when available.
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
import { SelectionController } from './SelectionController'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { LngFrameProvider, useLngFrame, useTripLngFrame } from './lngFrameContext'
import { frameLine, frameLng } from '../../utils/lngFrame'
import { LngFrameFollower, MapClickHandler, MapContextMenuHandler, PoiDropTarget, ViewportController } from './leafletMapBridges'
@@ -613,7 +709,6 @@ export const MapView = memo(function MapView({
leftWidth = 0,
rightWidth = 0,
hasInspector = false,
inspectorHeight = 0,
hasDayDetail = false,
reservations = [] as Reservation[],
showReservationStats = false,
@@ -666,6 +761,8 @@ export const MapView = memo(function MapView({
// that is decides which layer draws it. A saved raster template still wins,
// the default is a vector style.
const basemap = useMemo(() => resolveBasemap(tileUrl, OFM_POSITRON), [tileUrl])
const poiClickRef = useRef(onPoiClick)
poiClickRef.current = onPoiClick
/**
* Amap's tiles are drawn in GCJ-02 (see gcj02Crs.ts). The projection has to be
* decided before the map is constructed, because Leaflet cannot change a map's
@@ -677,6 +774,30 @@ export const MapView = memo(function MapView({
// drawn in a longitude frame that keeps it on one copy (#2975). Null for every other
// trip, and then each layer below draws exactly what it always drew.
const lngFrame = useTripLngFrame(places, dayPlaces, reservations)
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, frameLng(lngFrame, poi.lng)]}
icon={createPoiIcon(poi, poi.brand_wikidata)}
zIndexOffset={500}
eventHandlers={{
click: () => onPoiClick?.(poi),
// Wired on add rather than in a layout effect: Leaflet builds the icon element
// itself, and this is the first moment there is one to attach to.
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
const el = e.target.getElement()
if (!el) return
// Native clicks avoid Leaflet suppressing a click after an earlier map drag.
el.setAttribute('aria-label', poi.name)
el.onclick = event => { event.stopPropagation(); poiClickRef.current?.(poi) }
if (onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
},
}}
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip"><PoiTooltipBody poi={poi} /></Tooltip>
</Marker>
)), [pois, onPoiClick, onPoiDropOnRoute, lngFrame])
const visibleReservations = useMemo(() => (
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
@@ -694,10 +815,10 @@ export const MapView = memo(function MapView({
return {
top: 60,
right: rightWidth + 40,
bottom: bottomChromePadding({ hasInspector, inspectorHeight, hasDayDetail }),
bottom: hasInspector ? 320 : hasDayDetail ? 280 : 60,
left: leftWidth + 40,
}
}, [isMobile, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
}, [isMobile, leftWidth, rightWidth, hasInspector, hasDayDetail])
const paddingOpts = useMemo(() => leafletPadding(paddingBox), [paddingBox])
@@ -953,7 +1074,7 @@ export const MapView = memo(function MapView({
<MapController center={center} zoom={zoom} />
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingBox={paddingBox} />
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
@@ -1102,7 +1223,7 @@ export const MapView = memo(function MapView({
hiddenDates={dawarichHiddenDates}
casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined}
/>
<PoiMarkers pois={pois as Poi[]} clusterLoosely={clusterLoosely} onPoiClick={onPoiClick} draggable={!!onPoiDropOnRoute} />
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
{/* Charging stops / rest areas a plugin route places on the drawn day route.
Host-vetted data (server-normalized), rendered as plain tone dots. */}
{(routeVias as RouteVia[]).filter(v => !v.nightPause || mapZoom >= NIGHT_PAUSE_MIN_ZOOM).map((v, i) => (
+29 -13
View File
@@ -15,9 +15,7 @@ import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
import { useMarkerThumbs } from './useMarkerThumbs'
import { placeMarkerFrame, placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
import { bottomChromePadding } from './mapChromePadding'
import { useSelectionCameraGL } from './useSelectionCameraGL'
import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
import { markerPhotoHtml, placePhotoFull, placePhotoUrl } from './placePhoto'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
@@ -53,6 +51,7 @@ import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefault
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { onRealWorld } from '../../utils/worldCopy'
import { bindPoiDropTarget } from './poiDropTarget'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import {
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
addPlaceClusterLayers, drawClusterBubbles, expandCluster,
@@ -125,7 +124,10 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?
const look = placeMarkerLook(place, selected)
const { size, borderWidth } = look
// See MapView: allow-listed rather than escaped, because this is a CSS context.
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(selected, safeHexColor(place.category_color, 'white'))
const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white')
const shadow = selected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
const bgColor = safeHexColor(place.category_color, '#6b7280')
// The visual circle is `size` + 2*border on each side. To make the
@@ -167,7 +169,7 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?
const hasPhoto = look.showPhoto && photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/') || photoUrl.startsWith('/uploads/'))
if (hasPhoto) {
wrap.innerHTML = `
<div class="${frameClass}" style="
<div style="
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
@@ -181,7 +183,7 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?
`
} else {
wrap.innerHTML = `
<div class="${frameClass}" style="
<div style="
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
@@ -453,7 +455,6 @@ export function MapViewGL({
leftWidth = 0,
rightWidth = 0,
hasInspector = false,
inspectorHeight = 0,
hasDayDetail = false,
reservations = NO_RESERVATIONS,
visibleConnectionIds = NO_CONNECTION_IDS,
@@ -1916,9 +1917,9 @@ export function MapViewGL({
const paddingOpts = useMemo(() => {
if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 }
const top = 60
const bottom = bottomChromePadding({ hasInspector, inspectorHeight, hasDayDetail })
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
}, [isMobile, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
}, [isMobile, leftWidth, rightWidth, hasInspector, hasDayDetail])
useAssistantMapGL({ mapRef, mapReady, gl, pinsRef: assistantGhostPinsRef, attachPin: attachPlannerWaypointPin, markersRef, padding: paddingOpts, assistant })
const prevFitKey = useRef<number | null>(-1)
@@ -2010,10 +2011,25 @@ export function MapViewGL({
} catch { /* noop */ }
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
useSelectionCameraGL({
mapRef, selectedPlaceId, places, dayPlaces, selectedPlace,
follow: followSelection, padding: paddingOpts, enableMapbox3d,
})
// flyTo selected place
useEffect(() => {
const map = mapRef.current
if (!map || !selectedPlaceId || !followSelection) return
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
if (!target?.lat || !target?.lng) return
try {
map.flyTo({
center: [target.lng, target.lat],
zoom: Math.max(map.getZoom(), 14),
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
// Account for the side panels and the bottom inspector / day-detail panel
// so the selected pin lands in the centre of the *visible* map area rather
// than the geometric centre (where the bottom panel would cover it).
padding: paddingOpts,
})
} catch { /* noop */ }
}, [selectedPlaceId, enableMapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
// External center/zoom prop changes — jump without animation
const jumpedToRef = useRef<[number, number] | null>(null)
-136
View File
@@ -1,136 +0,0 @@
import L from 'leaflet';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { frameLng, viewLngFrame, type LngFrame } from '../../utils/lngFrame';
import ClusteredPois from './ClusteredPois';
import { makePoiDraggable } from './markerDrag';
import type { Poi } from './poiCategories';
import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker';
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
// markers of planned places; the colour matches its pill category.
const poiIconCache = new Map<string, L.DivIcon>();
function createPoiIcon(poi: Pick<Poi, 'category' | 'icon' | 'color'>) {
// One flat disc in the category's colour with its icon, and never the chain's logo.
// The brands turned a corridor full of petrol stations into a row of advertisements,
// they were unreadable at pin size, and a brand with no logo on file fell back to a
// different picture entirely — so no two pins looked alike.
// A plugin category's key carries its colour and icon too, so two looks never share a pin.
const cacheKey = poiPinCacheKey(poi);
const cached = poiIconCache.get(cacheKey);
if (cached) return cached;
const { color, svg } = poiPinParts(poi);
const icon = L.divIcon({
className: '',
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}</div>`,
iconSize: [26, 26],
iconAnchor: [13, 13],
tooltipAnchor: [0, -14],
});
poiIconCache.set(cacheKey, icon);
return icon;
}
/**
* The hover tooltip of an explore POI: its name, and for a plugin POI the rows the
* plugin knows about it (the GL hover card shows the same, see buildPoiPopupHtml).
* React text throughout, so nothing a plugin sends is ever read as markup. A POI
* without rows keeps the bare name it has always had.
*/
function PoiTooltipBody({ poi }: Readonly<{ poi: Poi }>) {
const rows = poiDetailRows(poi);
if (!rows.length) return poi.name;
// w-max because Leaflet's tooltip pane is 0px wide: without an intrinsic width the
// tooltip shrinks to its narrowest layout and the values wrap a letter per line. The
// label column stops at 45% and wraps, so a long label leaves its value room too.
return (
<div className="w-max max-w-56 whitespace-normal">
<div className="truncate font-semibold">{poi.name}</div>
<div className="mt-1 grid grid-cols-[fit-content(45%)_1fr] gap-x-1.5 font-normal" data-testid="poi-details">
{rows.map((row) => (
<div key={poiDetailRowKey(row)} className="contents">
<span className="text-content-muted [overflow-wrap:anywhere]">{row.label}</span>
<span className="[overflow-wrap:anywhere]">{row.value}</span>
</div>
))}
</div>
</div>
);
}
/**
* The explore POIs of the Leaflet map, clustered when asked. They are fetched for the
* visible area, which can lie outside the trip's longitude frame or on another world
* copy, so they are drawn on the copy in view, followed as the view moves (#2975).
*/
export default function PoiMarkers({
pois,
clusterLoosely,
onPoiClick,
draggable,
}: {
pois: Poi[];
clusterLoosely: boolean;
onPoiClick?: (poi: Poi) => void;
/** Whether a pin can be dragged onto the route. */
draggable: boolean;
}) {
const map = useMap();
const [frame, setFrame] = useState<LngFrame | null>(null);
// Followed only while there are POIs to draw, so a map without them keeps no listeners.
const tracking = pois.length > 0;
useEffect(() => {
if (!tracking) return undefined;
const follow = () => setFrame((current) => viewLngFrame(current, map.getCenter().lng));
map.whenReady(follow);
map.on('moveend', follow);
return () => {
map.off('moveend', follow);
};
}, [map, tracking]);
// Native clicks keep the newest handler without rebuilding every marker.
const clickRef = useRef(onPoiClick);
useEffect(() => {
clickRef.current = onPoiClick;
}, [onPoiClick]);
const markers = useMemo(
() =>
pois.map((poi) => (
<Marker
key={`poi-${poi.osm_id}`}
alt={poi.name}
position={[poi.lat, frameLng(frame, poi.lng)]}
icon={createPoiIcon(poi)}
zIndexOffset={500}
eventHandlers={{
click: () => onPoiClick?.(poi),
// Wired on add rather than in a layout effect: Leaflet builds the icon element
// itself, and this is the first moment there is one to attach to.
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
const el = e.target.getElement();
if (!el) return;
// Native clicks avoid Leaflet suppressing a click after an earlier map drag.
el.setAttribute('aria-label', poi.name);
el.onclick = (event) => {
event.stopPropagation();
clickRef.current?.(poi);
};
if (draggable) makePoiDraggable(el, poi.osm_id);
},
}}
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">
<PoiTooltipBody poi={poi} />
</Tooltip>
</Marker>
)),
[pois, onPoiClick, draggable, frame]
);
return (
<ClusteredPois pois={pois} frame={frame} enabled={clusterLoosely} onPoiClick={onPoiClick}>
{markers}
</ClusteredPois>
);
}
@@ -1,79 +0,0 @@
import type L from 'leaflet';
import { useEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import type { Place } from '../../types';
import { frameLng } from '../../utils/lngFrame';
import type { ViewportPadding } from '../../utils/mapViewport';
import { useLngFrame } from './lngFrameContext';
import { isUnderChrome } from './mapChromePadding';
import { selectedPlaceTarget } from './selectedPlaceTarget';
interface SelectionControllerProps {
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
follow: boolean;
places: Place[];
selectedPlaceId: number | null;
dayPlaces: Place[];
selectedPlace: Place | null;
paddingBox: ViewportPadding;
}
/**
* Where the Leaflet map goes when a place is selected: there, and out from under the
* panels. The GL twin is `useSelectionCameraGL`.
*/
export function SelectionController({
follow,
places,
selectedPlaceId,
dayPlaces,
selectedPlace,
paddingBox,
}: SelectionControllerProps) {
const map = useMap();
const frame = useLngFrame();
const prev = useRef<number | null>(null);
/** The panel geometry the last pan was measured against. */
const pannedWith = useRef(paddingBox);
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace);
const lat = target?.lat ?? null;
const lng = target?.lng == null ? null : frameLng(frame, target.lng);
useEffect(() => {
const isNew = selectedPlaceId !== prev.current;
prev.current = selectedPlaceId;
if (!isNew || !follow || !selectedPlaceId || lat == null || lng == null) return;
pannedWith.current = paddingBox;
panToPlace(map, [lat, lng], paddingBox);
}, [selectedPlaceId, follow, lat, lng, map, paddingBox]);
// The place card only learns its height after it laid out, and grows again when its
// details arrive. If it grew over the pin the selection placed, bring the pin back out;
// a pin still in the open part of the map stays where the traveller left it (#2991).
useEffect(() => {
if (pannedWith.current === paddingBox) return;
pannedWith.current = paddingBox;
if (!follow || !selectedPlaceId || lat == null || lng == null) return;
if (typeof map.latLngToContainerPoint !== 'function') return;
if (isUnderChrome(map.latLngToContainerPoint([lat, lng]), map.getSize(), paddingBox)) {
panToPlace(map, [lat, lng], paddingBox);
}
}, [paddingBox, follow, selectedPlaceId, lat, lng, map]);
return null;
}
/**
* Pan to a place without changing zoom. The centre is offset by the side panels and the
* bottom card, so the pin lands in the middle of the *visible* map area rather than the
* geometric centre, where the card would cover it.
*/
function panToPlace(map: L.Map, latlng: [number, number], padding: ViewportPadding): void {
if (typeof map.project !== 'function' || typeof map.unproject !== 'function') {
map.panTo(latlng, { animate: true });
return;
}
const { top, right, bottom, left } = padding;
const point = map.project(latlng).add([(right - left) / 2, (bottom - top) / 2]);
map.panTo(map.unproject(point), { animate: true });
}
@@ -1,4 +1,3 @@
import type { Ref } from 'react';
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher';
/** The card's gap to the side panels where nothing else sits at the map's edge. */
@@ -37,13 +36,3 @@ export function centredMapCard(
const width = `min(800px, calc(${free} - ${PILL_CLEARANCE + EDGE_GAP}px))`;
return { width, centre: `max(${centre}, calc(${leftWidth}px + ${PILL_CLEARANCE}px + ${width} / 2))` };
}
/** How a card that floats over the map is placed there, read by `centredMapCard`. */
export interface MapCardPlacement {
leftWidth?: number;
rightWidth?: number;
/** Over the desktop map, beside the base-layer pill. */
clearMapPill?: boolean;
/** The card's outer element, measured so the map keeps the selected pin above it (#2991). */
cardRef?: Ref<HTMLDivElement>;
}
@@ -1,43 +0,0 @@
import { describe, expect, it } from 'vitest';
import { bottomChromePadding, isUnderChrome, PLACE_CARD_OFFSET_PX } from './mapChromePadding';
describe('bottomChromePadding', () => {
it('MAPCHROME-001: keeps the old allowances when nothing was measured', () => {
expect(bottomChromePadding({})).toBe(60);
expect(bottomChromePadding({ hasDayDetail: true })).toBe(280);
expect(bottomChromePadding({ hasInspector: true })).toBe(320);
expect(bottomChromePadding({ hasInspector: true, hasDayDetail: true })).toBe(320);
});
it('MAPCHROME-002: never goes below the allowance for a short card', () => {
expect(bottomChromePadding({ hasInspector: true, inspectorHeight: 180 })).toBe(320);
});
it('MAPCHROME-003: covers a tall card with room to spare above it (#2991)', () => {
// A 60vh card on a portrait iPad, 1366px tall.
const card = 820;
const padding = bottomChromePadding({ hasInspector: true, inspectorHeight: card });
expect(padding).toBeGreaterThan(card + PLACE_CARD_OFFSET_PX);
});
it('MAPCHROME-004: ignores a measured card that is no longer open', () => {
expect(bottomChromePadding({ hasInspector: false, inspectorHeight: 820 })).toBe(60);
});
});
describe('isUnderChrome', () => {
const size = { x: 1000, y: 1200 };
const padding = { top: 60, right: 340, bottom: 900, left: 340 };
it('MAPCHROME-005: reads the open strip between the panels as clear', () => {
expect(isUnderChrome({ x: 500, y: 200 }, size, padding)).toBe(false);
expect(isUnderChrome({ x: 500, y: 300 }, size, padding)).toBe(false);
});
it('MAPCHROME-006: reads a point below the card top, beside a panel or under the header as covered', () => {
expect(isUnderChrome({ x: 500, y: 301 }, size, padding)).toBe(true);
expect(isUnderChrome({ x: 200, y: 200 }, size, padding)).toBe(true);
expect(isUnderChrome({ x: 700, y: 200 }, size, padding)).toBe(true);
expect(isUnderChrome({ x: 500, y: 40 }, size, padding)).toBe(true);
});
});
@@ -1,47 +0,0 @@
import type { ViewportPadding } from '../../utils/mapViewport';
/** How far above the map's bottom edge the place card floats (PlaceInspector's `bottom`). */
export const PLACE_CARD_OFFSET_PX = 20;
/** Map kept clear above the place card, so a pin panned there is not touching its edge. */
const PLACE_CARD_CLEARANCE_PX = 40;
/** What a place card is assumed to cover before it has been measured. */
const PLACE_CARD_MIN_COVER_PX = 320;
export interface BottomChrome {
hasInspector?: boolean;
/** The place card's measured height in pixels, 0 or absent while it is not on screen. */
inspectorHeight?: number;
hasDayDetail?: boolean;
}
/**
* How much of the map's bottom edge the floating panels cover, the same on both renderers.
*
* The place card grows with its content up to 60vh, which on a portrait tablet reaches past
* the middle of the map. A fixed allowance put the selected pin right under it (#2991), so
* the card's real height counts once it is known; the fixed value stays the floor for the
* moment between the selection and the card's first layout.
*/
export function bottomChromePadding({ hasInspector, inspectorHeight = 0, hasDayDetail }: BottomChrome): number {
if (hasInspector)
return Math.max(PLACE_CARD_MIN_COVER_PX, inspectorHeight + PLACE_CARD_OFFSET_PX + PLACE_CARD_CLEARANCE_PX);
return hasDayDetail ? 280 : 60;
}
/**
* Whether a point on the map's container sits under the panels around it rather than in
* the open part of the map. Used to bring the selected pin back out after a panel grew
* over it, and to leave the camera alone when it did not.
*/
export function isUnderChrome(
point: { x: number; y: number },
size: { x: number; y: number },
padding: ViewportPadding
): boolean {
return (
point.x < padding.left ||
point.x > size.x - padding.right ||
point.y < padding.top ||
point.y > size.y - padding.bottom
);
}
@@ -80,11 +80,6 @@ export interface MapViewProps {
leftWidth?: number;
rightWidth?: number;
hasInspector?: boolean;
/**
* The place card's measured height in pixels while it is open. The selection pans the
* pin above it rather than above a fixed allowance the card can outgrow (#2991).
*/
inspectorHeight?: number;
hasDayDetail?: boolean;
reservations?: Reservation[];
visibleConnectionIds?: number[];
@@ -9,7 +9,7 @@
import { describe, it, expect, afterEach } from 'vitest'
import L from 'leaflet'
import 'leaflet.markercluster'
import { CLUSTER_OPTIONS, CLUSTER_RADIUS_PX, CLUSTER_UNTIL_ZOOM, REVEAL_FAN_MAX_CHILDREN, revealInCluster } from './markerCluster'
import { CLUSTER_OPTIONS, CLUSTER_RADIUS_PX, CLUSTER_UNTIL_ZOOM, revealInCluster } from './markerCluster'
import { COINCIDENT_RADIUS_PX, STACK_RADIUS_PX } from './coincidentPlaces'
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults'
@@ -118,44 +118,4 @@ describe('reaching a stop inside a bubble', () => {
// And a parent from a group that cannot spiderfy is left alone rather than called.
expect(revealInCluster({ getVisibleParent: () => ({}) }, {})).toBe(false)
})
/** A city's worth of stops around HOTEL, a few hundred metres apart, as a big trip has them. */
function city(count: number): [number, number][] {
return Array.from({ length: count }, (_, i) => [HOTEL[0] + (i % 8) * 0.003, HOTEL[1] + Math.floor(i / 8) * 0.004])
}
const fanned = (markers: L.Marker[], start: [number, number][]) =>
markers.some((marker, i) => marker.getLatLng().lat !== start[i][0] || marker.getLatLng().lng !== start[i][1])
it('MARKERCLUSTER-009: leaves a city-sized bubble of the overview closed (#2990)', () => {
const coords = city(60)
const { group, markers } = stopsOn(mapAt(5), coords)
const bubble = group.getVisibleParent(markers[17])
expect(bubble).not.toBe(markers[17])
expect(revealInCluster(group, markers[17])).toBe(false)
expect(fanned(markers, coords)).toBe(false)
})
it('MARKERCLUSTER-010: still opens a small bubble of the overview', () => {
const coords = city(REVEAL_FAN_MAX_CHILDREN)
const { group, markers } = stopsOn(mapAt(5), coords)
expect(group.getVisibleParent(markers[0])).not.toBe(markers[0])
expect(revealInCluster(group, markers[0])).toBe(true)
expect(fanned(markers, coords)).toBe(true)
})
it('MARKERCLUSTER-011: opens a stack of any size when every stop shares one coordinate', () => {
const coords = Array.from({ length: REVEAL_FAN_MAX_CHILDREN + 8 }, () => HOTEL)
const { group, markers } = stopsOn(mapAt(16), coords)
expect(revealInCluster(group, markers[3])).toBe(true)
expect(new Set(markers.map(marker => `${marker.getLatLng().lat},${marker.getLatLng().lng}`)).size).toBe(coords.length)
})
it('MARKERCLUSTER-012: reads a bubble without bounds as one that cannot be told apart from a city', () => {
const spiderfy = () => { throw new Error('must not fan') }
const big = { spiderfy, getChildCount: () => REVEAL_FAN_MAX_CHILDREN + 1 }
expect(revealInCluster({ getVisibleParent: () => big }, {})).toBe(false)
})
})
+4 -37
View File
@@ -42,52 +42,19 @@ export interface ClusterGroupLike {
getVisibleParent?: (marker: unknown) => unknown
}
/** The part of a cluster bubble that `revealInCluster` reads. */
interface RevealableCluster {
spiderfy: () => void
getChildCount: () => number
getBounds?: () => { getNorthEast: () => L.LatLng; getSouthWest: () => L.LatLng }
}
/**
* The most stops a selection fans open around itself. Up to here the fan stays a small
* ring around the bubble; a city-sized bubble from the overview would throw hundreds of
* legs across the whole map instead (#2990).
*/
export const REVEAL_FAN_MAX_CHILDREN = 12
/**
* Whether opening this bubble shows the selection or buries the map.
*
* A small bubble always opens. A bigger one only when every stop in it sits on the same
* coordinate, the stack no zoom can take apart, which is the case the fan is there for.
* Anything else is a pile of ordinary neighbours that zooming in separates, so it stays
* closed and the selection is shown by the pan alone.
*/
function fansOpenOnSelect(cluster: RevealableCluster): boolean {
if (cluster.getChildCount() <= REVEAL_FAN_MAX_CHILDREN) return true
const bounds = cluster.getBounds?.()
return !!bounds && bounds.getNorthEast().equals(bounds.getSouthWest())
}
function isRevealableCluster(layer: unknown): layer is RevealableCluster {
const candidate = layer as Partial<RevealableCluster> | null
return !!candidate && typeof candidate.spiderfy === 'function' && typeof candidate.getChildCount === 'function'
}
/**
* Fan open the bubble a stop is hidden inside, if it is in one and small enough.
* Fan open the bubble a stop is hidden inside, if it is in one.
*
* Picking a stop from the places rail used to raise its pin out of the stack with a
* z-index. A stop sharing its coordinates now sits in a cluster instead and has no pin
* of its own to raise, so the bubble has to open for the selection to be visible at all.
* Deliberately not `zoomToShowLayer`: the zoom belongs to the day fit, and the selection
* only ever pans. See `fansOpenOnSelect` for which bubbles are opened.
* only ever pans.
*/
export function revealInCluster(group: ClusterGroupLike | null | undefined, marker: unknown): boolean {
if (!group || !marker || typeof group.getVisibleParent !== 'function') return false
const parent = group.getVisibleParent(marker)
if (parent === marker || !isRevealableCluster(parent) || !fansOpenOnSelect(parent)) return false
const parent = group.getVisibleParent(marker) as { spiderfy?: () => void } | null
if (!parent || parent === marker || typeof parent.spiderfy !== 'function') return false
parent.spiderfy()
return true
}
@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest';
import { placeMarkerFrame, placeMarkerLook, SELECTED_MARKER } from './markerLook';
describe('placeMarkerFrame', () => {
it('MARKERLOOK-001: gives the selected pin the white border, blue ring and pulse class (#2991)', () => {
expect(placeMarkerFrame(true, '#ff0000')).toEqual({
borderColor: SELECTED_MARKER.border,
shadow: SELECTED_MARKER.ring,
className: SELECTED_MARKER.className,
});
expect(SELECTED_MARKER.ring).toContain('#0a84ff');
});
it('MARKERLOOK-002: keeps every other pin in its category colour, without the class', () => {
const frame = placeMarkerFrame(false, '#ff0000');
expect(frame.borderColor).toBe('#ff0000');
expect(frame.className).toBe('');
expect(frame.shadow).not.toContain('#0a84ff');
});
});
describe('placeMarkerLook', () => {
it('MARKERLOOK-003: draws a selected pin larger than a planned one, even when it was compact', () => {
expect(placeMarkerLook({ _compact: true }, true).size).toBeGreaterThan(placeMarkerLook({}, false).size);
expect(placeMarkerLook({ _compact: true }, false).showPhoto).toBe(false);
});
});
-23
View File
@@ -1,5 +1,3 @@
import './selectedMarker.css'
/**
* Two ways a place marker can be told apart from the rest, set on the place object the
* planner hands to the map. Underscored because they are the map's reading of the plan,
@@ -41,24 +39,3 @@ export function placeMarkerLook(place: PlaceMarkerFlags, selected: boolean): Pla
key: `${compact ? 'c' : ''}${pending ? 'p' : ''}`,
}
}
/**
* How the selected place stands out from every other pin, the same on both renderers.
*
* Map paint rather than theme: it sits on tiles that are light, dark or a photo, beside
* pins whose border is their category colour. The old near-black border and grey halo
* vanished on dark tiles (#2991); a white border inside a saturated blue ring reads on
* all of them, and the class adds a slow pulse (selectedMarker.css) that stops under reduced motion.
*/
export const SELECTED_MARKER = {
border: '#ffffff', // theme-lint-disable: map paint, has to read on any tile
ring: '0 0 0 3px #0a84ff, 0 4px 14px rgba(0,0,0,0.35)',
className: 'trek-marker-selected',
} as const
/** The border colour and shadow of a round place marker, selected or not. */
export function placeMarkerFrame(selected: boolean, categoryBorder: string): { borderColor: string; shadow: string; className: string } {
return selected
? { borderColor: SELECTED_MARKER.border, shadow: SELECTED_MARKER.ring, className: SELECTED_MARKER.className }
: { borderColor: categoryBorder, shadow: '0 2px 8px rgba(0,0,0,0.22)', className: '' }
}
@@ -1,28 +0,0 @@
/* The selected place pin on either map (#2991). Its static ring is inline, so a browser
without the animation still shows it; the pulse only widens a fading second ring
around it, and the pin itself never moves. */
@keyframes trek-marker-selected-pulse {
0% {
box-shadow:
0 0 0 3px #0a84ff,
0 0 0 3px rgba(10, 132, 255, 0.55),
0 4px 14px rgba(0, 0, 0, 0.35);
}
100% {
box-shadow:
0 0 0 3px #0a84ff,
0 0 0 16px rgba(10, 132, 255, 0),
0 4px 14px rgba(0, 0, 0, 0.35);
}
}
.trek-marker-selected {
animation: trek-marker-selected-pulse 1.8s ease-out infinite;
}
/* Under reduced motion the ring stays, it just stops breathing. */
@media (prefers-reduced-motion: reduce) {
.trek-marker-selected {
animation: none;
}
}
@@ -1,79 +0,0 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { bottomChromePadding } from './mapChromePadding';
import { useSelectionCameraGL } from './useSelectionCameraGL';
/** A 1000x1200 map whose projection is lng/lat times ten. */
function glMap() {
return {
getZoom: vi.fn(() => 12),
flyTo: vi.fn(),
easeTo: vi.fn(),
project: vi.fn(([lng, lat]: [number, number]) => ({ x: lng * 10, y: lat * 10 })),
getContainer: vi.fn(() => ({ clientWidth: 1000, clientHeight: 1200 })),
};
}
// Projected to (500, 700) and (500, 200).
const low = { id: 1, lat: 70, lng: 50 };
const high = { id: 2, lat: 20, lng: 50 };
function padding(inspectorHeight: number) {
return { top: 60, right: 340, left: 340, bottom: bottomChromePadding({ hasInspector: true, inspectorHeight }) };
}
function setup(selectedPlaceId: number, follow = true) {
const map = glMap();
const mapRef = { current: map };
const view = renderHook(
({ selected, pad }) =>
useSelectionCameraGL({
mapRef,
selectedPlaceId: selected,
places: [low, high],
dayPlaces: [],
selectedPlace: null,
follow,
padding: pad,
enableMapbox3d: false,
}),
{ initialProps: { selected: selectedPlaceId, pad: padding(0) } }
);
return { map, ...view };
}
describe('useSelectionCameraGL', () => {
it('SELCAM-GL-001: flies to a new selection, padded by the panels, at least to zoom 14', () => {
const { map } = setup(1);
expect(map.flyTo).toHaveBeenCalledWith(
expect.objectContaining({ center: [50, 70], zoom: 14, padding: padding(0) })
);
});
it('SELCAM-GL-002: eases the pin back out when the place card grew over it (#2991)', () => {
const { map, rerender } = setup(1);
rerender({ selected: 1, pad: padding(900) });
expect(map.easeTo).toHaveBeenCalledWith({ center: [50, 70], padding: padding(900), duration: 300 });
expect(map.flyTo).toHaveBeenCalledTimes(1);
});
it('SELCAM-GL-003: leaves a pin that is still in the open where it is', () => {
const { map, rerender } = setup(2);
rerender({ selected: 2, pad: padding(900) });
expect(map.easeTo).not.toHaveBeenCalled();
});
it('SELCAM-GL-004: moves only once when the selection and the card arrive together', () => {
const { map, rerender } = setup(2);
rerender({ selected: 1, pad: padding(900) });
expect(map.flyTo).toHaveBeenCalledTimes(2);
expect(map.easeTo).not.toHaveBeenCalled();
});
it('SELCAM-GL-005: does not move a locked map at all', () => {
const { map, rerender } = setup(1, false);
rerender({ selected: 1, pad: padding(900) });
expect(map.flyTo).not.toHaveBeenCalled();
expect(map.easeTo).not.toHaveBeenCalled();
});
});
@@ -1,89 +0,0 @@
import { useEffect, useRef } from 'react';
import type { ViewportPadding } from '../../utils/mapViewport';
import { isUnderChrome } from './mapChromePadding';
import { selectedPlaceTarget, type MapTarget } from './selectedPlaceTarget';
/** The part of a MapLibre/Mapbox map the selection camera drives. */
interface SelectionCameraMap {
getZoom: () => number;
flyTo: (options: Record<string, unknown>) => unknown;
easeTo: (options: Record<string, unknown>) => unknown;
project?: (lngLat: [number, number]) => { x: number; y: number };
getContainer: () => { clientWidth: number; clientHeight: number };
}
interface SelectionCameraOptions<P extends MapTarget> {
mapRef: { readonly current: SelectionCameraMap | null | undefined };
selectedPlaceId: number | null;
places: P[];
dayPlaces: P[];
selectedPlace: P | null;
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
follow: boolean;
/** The side panels and the bottom card over the map, so the pin lands in the open part. */
padding: ViewportPadding;
enableMapbox3d: boolean;
}
/**
* Where the GL map goes when a place is selected: there, and out from under the panels.
*
* A selection flies to the pin, padded by the panels so it lands in the centre of the
* visible map rather than the geometric one. The place card only learns its height after
* it laid out and grows again when its details arrive; if it grew over the pin, the pin is
* eased back out above it at the same zoom, and one still in the open stays put (#2991).
*/
export function useSelectionCameraGL<P extends MapTarget>({
mapRef,
selectedPlaceId,
places,
dayPlaces,
selectedPlace,
follow,
padding,
enableMapbox3d,
}: SelectionCameraOptions<P>): void {
/** The panel geometry the last move to the selection was measured against. */
const movedWith = useRef(padding);
/** The selection (and 3D mode) the camera last flew for: only a change of it flies. */
const flownFor = useRef<string | null>(null);
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace);
const lat = target?.lat ?? null;
const lng = target?.lng ?? null;
useEffect(() => {
const key = `${selectedPlaceId}:${enableMapbox3d}`;
if (flownFor.current === key) return;
flownFor.current = key;
movedWith.current = padding;
const map = mapRef.current;
if (!map || !selectedPlaceId || !follow || !lat || !lng) return;
try {
map.flyTo({
center: [lng, lat],
zoom: Math.max(map.getZoom(), 14),
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
padding,
});
} catch {
/* noop */
}
}, [selectedPlaceId, enableMapbox3d, follow, lat, lng, mapRef, padding]);
useEffect(() => {
if (movedWith.current === padding) return;
movedWith.current = padding;
const map = mapRef.current;
if (!map || !selectedPlaceId || !follow || lat == null || lng == null) return;
if (typeof map.project !== 'function') return;
try {
const container = map.getContainer();
const size = { x: container.clientWidth, y: container.clientHeight };
if (!isUnderChrome(map.project([lng, lat]), size, padding)) return;
map.easeTo({ center: [lng, lat], padding, duration: 300 });
} catch {
/* noop */
}
}, [padding, selectedPlaceId, follow, lat, lng, mapRef]);
}
@@ -1,58 +0,0 @@
// FE-PACK-BRINGERCARD-001 to FE-PACK-BRINGERCARD-004
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { BringersCard, MoreBringers } from './PackingBringersCard';
import { bringersOf } from './packingBringers';
const members = [
{ id: 1, username: 'anna', avatar_url: '/uploads/avatars/anna.jpg' },
{ id: 2, username: 'ben', avatar: '/uploads/avatars/ben.jpg' },
{ id: 3, username: 'carla' },
];
describe('bringersOf', () => {
it('FE-PACK-BRINGERCARD-001: lists the bringer first, then the others, each with the picture the trip knows', () => {
const item = {
owner_id: 1,
owner_username: 'anna',
contributors: [
{ user_id: 2, username: 'ben' },
{ user_id: 3, username: 'carla' },
],
};
expect(bringersOf(item, members)).toEqual([
{ name: 'anna', avatar: '/uploads/avatars/anna.jpg' },
{ name: 'ben', avatar: '/uploads/avatars/ben.jpg' },
{ name: 'carla', avatar: null },
]);
});
it('FE-PACK-BRINGERCARD-002: lists a person once and copes with a bringer the trip no longer has', () => {
const item = { owner_id: 9, owner_username: 'dora', contributors: [{ user_id: 9, username: 'dora' }] };
expect(bringersOf(item, members)).toEqual([{ name: 'dora', avatar: null }]);
expect(bringersOf({}, members)).toEqual([]);
});
});
describe('BringersCard', () => {
it('FE-PACK-BRINGERCARD-003: names everyone on a line of their own under the heading', () => {
render(
<BringersCard
bringers={bringersOf(
{ owner_id: 1, owner_username: 'anna', contributors: [{ user_id: 3, username: 'carla' }] },
members
)}
/>
);
const rows = screen.getAllByRole('listitem');
expect(rows).toHaveLength(2);
expect(rows[0]).toHaveTextContent('anna');
expect(rows[1]).toHaveTextContent('carla');
expect(screen.getByText('Brought by')).toBeInTheDocument();
});
it('FE-PACK-BRINGERCARD-004: the "+N" circle counts the others', () => {
const { container } = render(<MoreBringers count={3} />);
expect(container).toHaveTextContent('+3');
});
});
@@ -1,91 +0,0 @@
import type { CSSProperties } from 'react';
import { useTranslation } from '../../i18n';
import type { Bringer } from './packingBringers';
const CIRCLE: CSSProperties = {
width: 20,
height: 20,
borderRadius: '50%',
flexShrink: 0,
display: 'grid',
placeItems: 'center',
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
fontWeight: 700,
};
/** A bringer's picture, or the first letter of the name on a colour taken from it. */
export function BringerAvatar({ bringer }: { bringer: Bringer }) {
if (bringer.avatar) {
return <img src={bringer.avatar} alt="" style={{ ...CIRCLE, objectFit: 'cover', display: 'block' }} />;
}
return (
<span
aria-hidden
style={{
...CIRCLE,
background: `hsl(${((bringer.name.codePointAt(0) ?? 0) * 37) % 360}, 55%, 55%)`, // theme-lint-disable: the colour comes from the bringer's name
color: 'white',
textTransform: 'uppercase',
}}
>
{bringer.name[0]}
</span>
);
}
/** The "+N" of the others bringing it, as a circle tucked under the first bringer's picture. */
export function MoreBringers({ count }: { count: number }) {
return (
<span
aria-hidden
style={{
...CIRCLE,
marginInlineStart: -6,
color: 'var(--text-muted)',
background: 'var(--bg-tertiary)',
boxShadow: '0 0 0 2px var(--bg-card)',
fontVariantNumeric: 'tabular-nums',
}}
>
+{count}
</span>
);
}
/** What the row's tooltip shows: everyone bringing the item, one per line with their picture. */
export function BringersCard({ bringers }: { bringers: readonly Bringer[] }) {
const { t } = useTranslation();
return (
<div style={{ minWidth: 150 }}>
<div
className="text-content-faint"
style={{
fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))',
fontWeight: 700,
letterSpacing: '.06em',
textTransform: 'uppercase',
paddingBottom: 6,
}}
>
{t('packing.bringersTitle')}
</div>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{bringers.map((b, i) => (
<li
key={b.name}
className={i > 0 ? 'border-t border-edge-faint' : undefined}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}
>
<BringerAvatar bringer={b} />
<span
className="text-content"
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}
>
{b.name}
</span>
</li>
))}
</ul>
</div>
);
}
@@ -379,16 +379,13 @@ describe('ArtikelZeile — category picker', () => {
})
describe('ArtikelZeile — sharing badges', () => {
it('FE-W5ROW-018: an item somebody else brings shows their name in the TREK tooltip', async () => {
it('FE-W5ROW-018: an item somebody else brings shows their name', () => {
setup({
item: buildPackingItem({ id: 1, name: 'Stove', is_private: 1, owner_id: 2, owner_username: 'Bob' } as Partial<PackingItem>),
currentUserId: 1,
})
const badge = screen.getByLabelText('by Bob')
expect(badge).not.toHaveAttribute('title')
fireEvent.mouseEnter(badge)
expect(await screen.findByRole('tooltip')).toHaveTextContent('by Bob')
expect(screen.getByLabelText('by Bob')).toHaveAttribute('title', 'by Bob')
})
it('FE-W5ROW-019: a nameless bringer degrades to an empty name', () => {
@@ -397,10 +394,10 @@ describe('ArtikelZeile — sharing badges', () => {
currentUserId: 1,
})
expect(screen.getByLabelText('by')).toBeInTheDocument()
expect(screen.getByTitle('by')).toBeInTheDocument()
})
it('FE-W5ROW-020: an item shared by me counts its recipients', async () => {
it('FE-W5ROW-020: an item shared by me counts its recipients', () => {
setup({
item: buildPackingItem({
id: 1, name: 'Stove', is_private: 1, owner_id: 1,
@@ -410,11 +407,10 @@ describe('ArtikelZeile — sharing badges', () => {
})
expect(screen.getByLabelText('shared with 2')).toHaveTextContent('2')
fireEvent.mouseEnter(screen.getByLabelText('shared with 2'))
expect(await screen.findByRole('tooltip')).toHaveTextContent('shared with 2: alice, bob')
expect(screen.getByTitle('shared with 2: alice, bob')).toBeInTheDocument()
})
it('FE-W5ROW-021: a common item names its bringer and counts co-bringers', async () => {
it('FE-W5ROW-021: a common item names its bringer and counts co-bringers', () => {
setup({
item: buildPackingItem({
id: 1, name: 'Stove', is_private: 0, owner_username: 'Bob',
@@ -423,15 +419,8 @@ describe('ArtikelZeile — sharing badges', () => {
currentUserId: 1,
})
// The bringer's initial on an avatar, the co-bringers as a "+N" circle; the tooltip
// names everyone, each on a line of their own (#2782).
const badge = screen.getByLabelText('brought by Bob, cleo')
expect(badge).toHaveTextContent('B+1')
fireEvent.mouseEnter(badge)
const rows = within(await screen.findByRole('tooltip')).getAllByRole('listitem')
expect(rows).toHaveLength(2)
expect(rows[0]).toHaveTextContent('Bob')
expect(rows[1]).toHaveTextContent('cleo')
// The bringer's initial on an avatar, the co-bringers as a count; the title names everyone (#2782).
expect(screen.getByTitle('brought by Bob, cleo')).toHaveTextContent('B+1')
})
it('FE-W5ROW-022: a bringer without co-bringers shows no counter', () => {
@@ -440,7 +429,7 @@ describe('ArtikelZeile — sharing badges', () => {
currentUserId: 1,
})
expect(screen.getByLabelText('brought by Bob')).toHaveTextContent(/^B$/)
expect(screen.getByTitle('brought by Bob')).toHaveTextContent(/^B$/)
})
it('FE-W5ROW-023: the placeholder row carries no badges and no share control', () => {
@@ -18,9 +18,7 @@ import { PopoverItem } from './PackingPopover'
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
import type { TripMember } from './usePackingListPanel'
import { NumericInput } from '../shared/NumericInput'
import { bringersOf, packingBringerNames } from './packingBringers'
import { Tooltip } from '../shared/Tooltip'
import { BringerAvatar, BringersCard, MoreBringers } from './PackingBringersCard'
import { packingBringerNames } from './packingBringers'
interface ArtikelZeileProps {
item: PackingItem
@@ -80,6 +78,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
const recipients = item.recipients || []
const sharedByMe = !!item.is_private && item.owner_id === currentUserId && recipients.length > 0
const broughtBy = !item.is_private && item.owner_username ? item.owner_username : null
const contributors = item.contributors || []
const canShare = canEdit && !isPlaceholder && !!onSetSharing
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
@@ -115,7 +114,8 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
// A multi-piece item counts its pieces into the bag (#2296).
const packed = packedOf(item)
const partlyPacked = !item.checked && packed > 0
const bringers = bringersOf(item, tripMembers)
const owner = tripMembers.find(m => m.id === item.owner_id)
const ownerAvatar = owner?.avatar_url ?? owner?.avatar ?? null
// At rest a row reads as what it holds (owner, a quantity above one, a
// weight, the bag) and everything else stays in place but dimmed: nothing
@@ -215,29 +215,31 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
{/* Sharing badges (#858 three-tier), as compact chips like the phone's
(#1525): the full sentence is the tooltip and the accessible name. */}
{!isPlaceholder && sharedToMe && (
<Tooltip label={t('packing.takenCareOf', { name: item.owner_username || '' })}>
<span className="packing-row-badge" aria-label={t('packing.takenCareOf', { name: item.owner_username || '' })}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, width: 22, height: 22, borderRadius: 99, color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 12%, transparent)' }}>
<HandHelping size={12} />
</span>
</Tooltip>
<span className="packing-row-badge" title={t('packing.takenCareOf', { name: item.owner_username || '' })} aria-label={t('packing.takenCareOf', { name: item.owner_username || '' })}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, width: 22, height: 22, borderRadius: 99, color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 12%, transparent)' }}>
<HandHelping size={12} />
</span>
)}
{!isPlaceholder && sharedByMe && (
<Tooltip label={`${t('packing.sharedWithCount', { count: recipients.length })}: ${recipients.map(r => r.username).join(', ')}`}>
<span className="packing-row-badge" aria-label={t('packing.sharedWithCount', { count: recipients.length })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, height: 22, padding: '0 7px', borderRadius: 99, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', background: 'var(--bg-tertiary)' }}>
<UserRound size={11} /> {recipients.length}
</span>
</Tooltip>
<span className="packing-row-badge" title={`${t('packing.sharedWithCount', { count: recipients.length })}: ${recipients.map(r => r.username).join(', ')}`} aria-label={t('packing.sharedWithCount', { count: recipients.length })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, height: 22, padding: '0 7px', borderRadius: 99, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', background: 'var(--bg-tertiary)' }}>
<UserRound size={11} /> {recipients.length}
</span>
)}
{!isPlaceholder && broughtBy && (
<Tooltip label={t('packing.broughtBy', { name: packingBringerNames(item) })} content={<BringersCard bringers={bringers} />}>
<span className="packing-row-badge" aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>
<BringerAvatar bringer={bringers[0]} />
{bringers.length > 1 && <MoreBringers count={bringers.length - 1} />}
</span>
</Tooltip>
<span className="packing-row-badge" title={t('packing.broughtBy', { name: packingBringerNames(item) })} aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
{ownerAvatar
? <img src={ownerAvatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover', display: 'block' }} />
: (
<span aria-hidden style={{
width: 20, height: 20, borderRadius: '50%', display: 'grid', placeItems: 'center',
background: `hsl(${(broughtBy.codePointAt(0) ?? 0) * 37 % 360}, 55%, 55%)`, // theme-lint-disable: the colour comes from the bringer's name
color: 'white', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase',
}}>{broughtBy[0]}</span>
)}
{contributors.length > 0 && <span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)' }}>+{contributors.length}</span>}
</span>
)}
<div className="packing-row-inline-actions" style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
@@ -1,5 +1,3 @@
import type { TripMember } from './usePackingListPanel';
interface BroughtItem {
owner_username?: string | null;
contributors?: { username: string }[] | null;
@@ -14,34 +12,3 @@ export function packingBringerNames(item: BroughtItem): string {
const names = [item.owner_username, ...(item.contributors ?? []).map((c) => c.username)];
return [...new Set(names.filter((n): n is string => !!n))].join(', ');
}
/** One person bringing a common item: the name, and the picture when the trip knows one. */
export interface Bringer {
name: string;
avatar: string | null;
}
/** The item's bringer first, then whoever also brings it, each once, with their picture from the trip's members. */
export function bringersOf(
item: {
owner_id?: number | null;
owner_username?: string | null;
contributors?: { user_id: number; username: string }[] | null;
},
members: readonly TripMember[]
): Bringer[] {
const avatarOf = (id: number | null | undefined) => {
const member = id == null ? undefined : members.find((m) => m.id === id);
return member?.avatar_url ?? member?.avatar ?? null;
};
const all = [
...(item.owner_username ? [{ id: item.owner_id ?? null, name: item.owner_username }] : []),
...(item.contributors ?? []).map((c) => ({ id: c.user_id, name: c.username })),
];
const bringers: Bringer[] = [];
for (const person of all) {
if (bringers.some((b) => b.name === person.name)) continue;
bringers.push({ name: person.name, avatar: avatarOf(person.id) });
}
return bringers;
}
@@ -1122,7 +1122,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const budgetItems = useTripStore(s => s.budgetItems)
const costsEnabled = useAddonStore(s => s.isEnabled('budget'))
const totalCostLabel = useMemo(() => {
return formatMoneySum(planCosts({ days, assignments, reservations, budgetItems, costsEnabled, tripCurrency: currency, displayCurrency: costBase }).total, costBase, locale, fxRates)
const costs = planCosts({ days, assignments, reservations, budgetItems, costsEnabled, tripCurrency: currency })
return formatMoneySum(costs.total, costBase, locale, fxRates)
}, [days, assignments, reservations, budgetItems, costsEnabled, currency, costBase, locale, fxRates])
return {
@@ -39,7 +39,7 @@ import { formatDistance, formatElevation } from '../../utils/units'
import { navigationTargetLabel, getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { TRANSPORT_TYPES, getPlaceBookings } from '../../utils/dayMerge'
import { NavigationMenu } from '../shared/NavigationMenu'
import { centredMapCard, type MapCardPlacement } from '../Map/centredMapCard'
import { centredMapCard } from '../Map/centredMapCard'
import { resolveOpenNow, resolvePlaceTimeZone, getWeekdayIndex, type OpeningPeriod } from './placeOpenState'
import { convertHoursLine } from './placeHoursFormat'
import type { EndDayControlProps } from '../Roadtrip/EndDayControl'
@@ -152,7 +152,7 @@ interface TripMember {
is_guest?: boolean
}
interface PlaceInspectorProps extends MapCardPlacement {
interface PlaceInspectorProps {
roadtripActive?: boolean
roadtripEndDay?: EndDayControlProps
roadtripStay?: RoadtripStayControl
@@ -188,6 +188,9 @@ interface PlaceInspectorProps extends MapCardPlacement {
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
/** Cast/clear the current user's star vote (#1435); enables the rating row. */
onRate?: (placeId: number, rating: number | null) => Promise<void> | void
leftWidth?: number
rightWidth?: number
clearMapPill?: boolean // over the desktop map, beside the base-layer pill
// ── Collection-mode props ──
collectionStatus?: CollectionStatus
onCopyToTrip?: () => void
@@ -209,7 +212,7 @@ export default function PlaceInspector({
assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking,
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onRate,
leftWidth = 0, rightWidth = 0, clearMapPill = false, cardRef,
leftWidth = 0, rightWidth = 0, clearMapPill = false,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) {
// Editing the place is a place right. The planner hands the handlers over regardless, and a member
@@ -366,7 +369,7 @@ export default function PlaceInspector({
const mapCard = centredMapCard(leftWidth, rightWidth, { clearPill: clearMapPill })
return (
<div ref={cardRef}
<div
style={{
position: 'absolute',
bottom: 20,
@@ -83,7 +83,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
initialFiles={fileImportFiles}
/>
{/* With Tours on, a route or track file from this column lands in the Tours preview. */}
<TourGpxPreviewDialog gpxImport={tourFileImport} offerPoints />
<TourGpxPreviewDialog gpxImport={tourFileImport} />
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
{categoryPickerOpen && (
<PlacesBulkCategoryModal
@@ -65,19 +65,6 @@ describe('planCosts (#2551)', () => {
expect(costs.byDay.get(2)).toEqual([{ amount: 100, currency: 'USD' }, { amount: 30, currency: 'EUR' }])
})
it('counts an expense entered in the display currency as typed, not through the trip currency (#2965)', () => {
// A yen trip read in euros: the flight was typed in euros, frozen at 0.008 EUR per yen.
const flight = buildBudgetItem({ total_price: 1000, currency: 'EUR', exchange_rate: 0.008, place_id: 51 })
const hotel = buildBudgetItem({ total_price: 20000, currency: null, place_id: 51 })
const read = { ...base, tripCurrency: 'JPY', budgetItems: [flight, hotel], costsEnabled: true }
expect(planCosts({ ...read, displayCurrency: 'eur' }).total).toEqual([
{ amount: 1000, currency: 'EUR' },
{ amount: 20000, currency: 'JPY' },
])
// Without a display currency (the PDF), the euros still count at their frozen rate.
expect(planCosts(read).total).toEqual([{ amount: 1000 / 0.008 + 20000, currency: 'JPY' }])
})
it('nets a refund against the expenses of its day', () => {
const items = [
buildBudgetItem({ total_price: 200, currency: null, place_id: 50 }),
+3 -11
View File
@@ -1,6 +1,6 @@
import type { AssignmentsMap, BudgetItem, Day, Reservation } from '../../types'
import type { MoneyEntry } from '../../utils/formatters'
import { bookedInTrip, inDisplayCurrency } from '../../hooks/useExchangeRates'
import { bookedInTrip } from '../../hooks/useExchangeRates'
import { daySpan, startKey } from './bookings/bookingsModel'
/** What the plan's cost figures add up: per day, and for the whole trip. */
@@ -22,22 +22,18 @@ export interface PlanCosts {
* An expense counts on the day its booking starts, else the first day its place is
* planned on, else the day of its date. One that belongs to no day still counts in
* the total. A foreign amount booked at a frozen rate counts at that rate, as Costs
* counts it. One entered in `displayCurrency`, when the caller reads the plan in one,
* counts as typed, as Costs shows it (#2965): booked into the trip currency and back
* at today's rate it drifted off the amount entered. The PDF passes none, since it
* adds everything up in the trip currency.
* counts it.
*
* With Costs off there are no expenses, and a place's own price (set through the
* API) still counts, once, on the first day the place is planned on.
*/
export function planCosts({ days, assignments, reservations, budgetItems, costsEnabled, tripCurrency, displayCurrency }: {
export function planCosts({ days, assignments, reservations, budgetItems, costsEnabled, tripCurrency }: {
days: Day[]
assignments: AssignmentsMap
reservations: Reservation[]
budgetItems: BudgetItem[]
costsEnabled: boolean
tripCurrency: string
displayCurrency?: string | null
}): PlanCosts {
const trip = (tripCurrency || 'EUR').toUpperCase()
const firstDayOfPlace = new Map<number, number>()
@@ -88,10 +84,6 @@ export function planCosts({ days, assignments, reservations, budgetItems, costsE
for (const item of budgetItems) {
const amount = item.total_price || 0
if (amount === 0) continue
if (displayCurrency && inDisplayCurrency(item.currency, trip, displayCurrency)) {
add(dayOfExpense(item), { amount, currency: displayCurrency.toUpperCase() })
continue
}
const booked = bookedInTrip(amount, item.currency, item.exchange_rate, trip)
const entry = booked != null
? { amount: booked, currency: trip }
@@ -104,24 +104,6 @@ describe('DayHeader', () => {
expect(container.querySelector('time')).toHaveTextContent('6/2')
})
it('FE-ROADTRIP-HEADERS-003c: a chosen date format sets the order and separator on the tile, whatever the clock', () => {
const tileDate = (time_format: string, date_format: string): string | null => {
useSettingsStore.setState({ settings: { distance_unit: 'metric', time_format, date_format } as never })
const { container, unmount } = wrap(<DayHeader day={day({ date: '2026-10-05' })} />)
const text = container.querySelector('time')?.textContent ?? null
unmount()
return text
}
// 5 October: a 12-hour reader who writes the day first no longer sees "10/5".
expect(tileDate('12h', 'DD/MM/YYYY')).toBe('5/10')
expect(tileDate('24h', 'YYYY-MM-DD')).toBe('10-5')
expect(tileDate('12h', 'DD.MM.YYYY')).toBe('5.10.')
expect(tileDate('24h', 'MM/DD/YYYY')).toBe('10/5')
// On auto the clock still decides, as before the format existed.
expect(tileDate('12h', 'auto')).toBe('10/5')
expect(tileDate('24h', 'auto')).toBe('5.10.')
})
it('FE-ROADTRIP-HEADERS-004: the pills never wrap but give way in steps as the row narrows, and a day of only its night has no drive and no count', () => {
// Room for everything: the drive with its time, the count with its word. The old
// narrow flag no longer decides it; the row measures its own room.
@@ -6,8 +6,6 @@ import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { useSettingsStore } from '../../store/settingsStore'
import { useDateFormat } from '../../hooks/useDateFormat'
import { dateLayout } from '../../utils/dateFormat'
import { formatDistance } from '../../utils/units'
import { formatDate } from '../../utils/formatters'
import { formatDurationShort } from './roadtripModel'
@@ -91,22 +89,13 @@ export function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow
}
/**
* A date short enough for the day tile: day and month, unpadded, in the order and with
* the separator of the reader's date format ("5/10", "10-5", "5.10."). With the format
* on 'auto' the clock setting still decides, as it did before the format existed: month
* first with the 12-hour clock ("10/5"), day first with the 24-hour one ("5.10.").
* A date short enough for the day tile, in the order the reader's clock setting implies:
* month first with the 12-hour clock ("10/5"), day first with the 24-hour one ("5.10.").
*/
function shortDate(iso: string, is12h: boolean, format?: string | null): string {
function shortDate(iso: string, is12h: boolean): string {
const [, m, d] = iso.split("-").map(Number)
if (!m || !d) return iso
const layout = dateLayout(format)
if (!layout) return is12h ? `${m}/${d}` : `${d}.${m}.`
const text = layout.order
.filter(field => field !== 'year')
.map(field => (field === 'day' ? d : m))
.join(layout.separator)
// The dotted layout closes on a dot the way it is written in full ("5.10.2026").
return layout.separator === '.' ? `${text}.` : text
return is12h ? `${m}/${d}` : `${d}.${m}.`
}
@@ -150,7 +139,6 @@ function flagTexts(day: RoadtripDay, followable: boolean, t: ReturnType<typeof u
export function DayTile({ day, tint }: { day: RoadtripDay; tint: string | null }): React.ReactElement {
const { language } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === "12h"
const dateFormat = useDateFormat()
return (
<span
className="flex w-9 flex-none flex-col items-center overflow-hidden rounded-[10px] font-geist font-bold tabular-nums shadow-sm"
@@ -166,7 +154,7 @@ export function DayTile({ day, tint }: { day: RoadtripDay; tint: string | null }
<>
<span aria-hidden className="h-px w-[60%] flex-none bg-current opacity-25" />
<time dateTime={day.date} aria-label={formatDate(day.date, language)} className="grid h-6 w-full place-items-center font-semibold" style={{ ...fs(9.5), lineHeight: 1 }}>
{shortDate(day.date, is12h, dateFormat)}
{shortDate(day.date, is12h)}
</time>
</>
) : null}
@@ -3,7 +3,6 @@ import { ParkingSquare } from 'lucide-react'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { isServiceStopType, serviceColor } from './roadtripModel'
import { STOP_KIND_BY_KEY } from './stopKinds'
import { SELECTED_MARKER } from '../Map/markerLook'
/**
* Smaller than a place marker on purpose.
@@ -43,9 +42,10 @@ export function serviceMarkerHtml(stopType: string | null | undefined, selected:
color: '#fff',
strokeWidth: 2.4,
}))
// Selected, it carries the same ring as a selected place pin, so the two read alike.
const ring = selected ? SELECTED_MARKER.ring : '0 2px 6px rgba(0,0,0,0.25)'
return `<div class="${selected ? SELECTED_MARKER.className : ''}" style="
const ring = selected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 12px rgba(0,0,0,0.3)'
: '0 2px 6px rgba(0,0,0,0.25)'
return `<div style="
width:${size}px;height:${size}px;border-radius:50%;
background:${serviceColor(stopType)};
border:${BORDER}px solid #fff;box-shadow:${ring};
@@ -1,4 +1,4 @@
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-009
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-007
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
// imported as one, and when a track was recorded shown where it carried times.
import { fireEvent, render, screen } from '@testing-library/react';
@@ -86,7 +86,7 @@ describe('TourGpxPreviewDialog (ACT-01)', () => {
describe('TourGpxPreviewDialog with points beside the lines (#2969)', () => {
it('FE-GPX-PREVIEW-005: offers the points as places, on by default, and imports them with the tour', () => {
const current = gpxImport([track()], { points: 4, areas: 0, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} offerPoints />);
render(<TourGpxPreviewDialog gpxImport={current} />);
expect(screen.getByTestId('gpx-preview-points')).toBeInTheDocument();
// Added as places, they are not listed as left out.
expect(screen.queryByTestId('gpx-preview-left-out')).toBeNull();
@@ -96,7 +96,7 @@ describe('TourGpxPreviewDialog with points beside the lines (#2969)', () => {
it('FE-GPX-PREVIEW-006: switched off, the points are named as left out and not imported', () => {
const current = gpxImport([track()], { points: 4, areas: 1, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} offerPoints />);
render(<TourGpxPreviewDialog gpxImport={current} />);
fireEvent.click(screen.getByRole('button', { name: /tours\.import\.pointsAsPlaces|Add the points as places/ }));
expect(screen.getByTestId('gpx-preview-left-out')).toHaveTextContent(/tours\.import\.leftOutPoints|4 pins/);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
@@ -105,7 +105,7 @@ describe('TourGpxPreviewDialog with points beside the lines (#2969)', () => {
it('FE-GPX-PREVIEW-007: a file whose every track the trip holds can still bring its points', () => {
const current = gpxImport([track({ duplicate: true })], { points: 2, areas: 0, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} offerPoints />);
render(<TourGpxPreviewDialog gpxImport={current} />);
const button = screen.getByTestId('gpx-preview-import');
expect(button).toBeEnabled();
expect(button).toHaveTextContent(/tours\.import\.pointsConfirm|Add places/);
@@ -113,20 +113,4 @@ describe('TourGpxPreviewDialog with points beside the lines (#2969)', () => {
fireEvent.click(button);
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: true });
});
it('FE-GPX-PREVIEW-008: not offered, as for a recording picked for a tour, the points stay out', () => {
const current = gpxImport([track()], { points: 4, areas: 0, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} initial={{ tour_type: 'hike' }} />);
expect(screen.queryByTestId('gpx-preview-points')).toBeNull();
expect(screen.getByTestId('gpx-preview-left-out')).toHaveTextContent(/tours\.import\.leftOutPoints|4 pins/);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: false });
});
it('FE-GPX-PREVIEW-009: not offered, a file of known tracks has nothing to import, points or not', () => {
const current = gpxImport([track({ duplicate: true })], { points: 2, areas: 0, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} />);
expect(screen.getByTestId('gpx-preview-import')).toBeDisabled();
expect(screen.getByText(/tours\.import\.previewAllDuplicates|nothing to import/)).toBeInTheDocument();
});
});
@@ -113,8 +113,8 @@ function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut {
/**
* #2969: the single points of the files (GPX waypoints, KML pins), which no
* tour is made of, offered as places of the trip where the dialog's caller
* asks for it.
* tour is made of, offered as places of the trip. On by default: a file with
* a route and its stops is usually meant as both.
*/
function PreviewPoints({ count, on, onToggle }: { count: number; on: boolean; onToggle: () => void }) {
const { t } = useTranslation();
@@ -183,22 +183,18 @@ function PreviewLeftOut({ leftOut }: { leftOut: TourFileLeftOut }) {
* as recorded, gets no riding time and is not checked for access. Closing it
* stores nothing. `initial` presets the type, as a recording imported for a
* tour takes that tour's (ACT-01); left out, it is a hike. A KML or KMZ file
* is shown the same way, with what it holds besides (IO-02). `offerPoints`
* offers the file's single points as places (#2969); only the places column
* asks for it, a recording picked for a tour is imported for its line alone.
* is shown the same way, with what it holds besides (IO-02).
*/
export default function TourGpxPreviewDialog({
gpxImport,
initial,
offerPoints = false,
}: {
gpxImport: TourGpxImport;
initial?: TourGpxImportFields;
offerPoints?: boolean;
}) {
const { preview } = gpxImport;
if (!preview) return null;
return <PreviewDialog gpxImport={gpxImport} files={preview} initial={initial ?? HIKE} offerPoints={offerPoints} />;
return <PreviewDialog gpxImport={gpxImport} files={preview} initial={initial ?? HIKE} />;
}
const HIKE: TourGpxImportFields = { tour_type: 'hike' };
@@ -211,20 +207,16 @@ function canConfirm(fresh: number, addsPoints: boolean, typeComplete: boolean):
/** The points offered as places, and below them whatever else stays out. */
function PreviewExtras({
leftOut,
offerPoints,
withPoints,
onTogglePoints,
}: {
leftOut: TourFileLeftOut;
offerPoints: boolean;
withPoints: boolean;
onTogglePoints: () => void;
}) {
return (
<>
{offerPoints && leftOut.points > 0 && (
<PreviewPoints count={leftOut.points} on={withPoints} onToggle={onTogglePoints} />
)}
{leftOut.points > 0 && <PreviewPoints count={leftOut.points} on={withPoints} onToggle={onTogglePoints} />}
<PreviewLeftOut leftOut={withPoints ? { ...leftOut, points: 0 } : leftOut} />
</>
);
@@ -234,20 +226,17 @@ function PreviewDialog({
gpxImport,
files,
initial,
offerPoints,
}: {
gpxImport: TourGpxImport;
files: GpxPreviewFile[];
initial: TourGpxImportFields;
offerPoints: boolean;
}) {
const { t } = useTranslation();
const labelId = useId();
const tileUrl = useTileUrl(OFM_POSITRON);
const choices = useGpxImportChoices(initial);
const leftOut = useMemo(() => leftOutOf(files), [files]);
// On by default where offered: a file with a route and its stops is usually meant as both.
const [withPoints, setWithPoints] = useState(offerPoints);
const [withPoints, setWithPoints] = useState(true);
const addsPoints = withPoints && leftOut.points > 0;
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
@@ -325,12 +314,7 @@ function PreviewDialog({
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} number={index + 1} track={track} />
))}
</ul>
<PreviewExtras
leftOut={leftOut}
offerPoints={offerPoints}
withPoints={withPoints}
onTogglePoints={() => setWithPoints((on) => !on)}
/>
<PreviewExtras leftOut={leftOut} withPoints={withPoints} onTogglePoints={() => setWithPoints((on) => !on)} />
{fresh === 0 && !addsPoints && (
<div className="mt-2">
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
@@ -458,7 +458,7 @@ describe('ToursSidebar', () => {
left_out: { points: 3, areas: 1, other: 0 },
})
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
const pinsCall = vi.spyOn(placesApi, 'importMapFile')
const pinsCall = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 3 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
@@ -471,11 +471,11 @@ describe('ToursSidebar', () => {
const rows = screen.getAllByTestId('gpx-preview-track')
expect(rows.map((row) => row.textContent?.slice(0, 1))).toEqual(['1', '2'])
expect(rows[1]).toHaveTextContent('No heights in the file')
// Only the places column offers the pins as places (#2969); here they stay out.
expect(screen.queryByTestId('gpx-preview-points')).toBeNull()
// The pins are offered as places (#2969); only the area stays out.
expect(screen.getByTestId('gpx-preview-points')).toHaveTextContent('3 pins')
const leftOut = screen.getByTestId('gpx-preview-left-out')
expect(leftOut).toHaveTextContent('Not imported')
expect(leftOut).toHaveTextContent('3 pins')
expect(leftOut).not.toHaveTextContent('pins')
expect(leftOut).toHaveTextContent('1 area')
expect(leftOut).not.toHaveTextContent('other item')
expect(within(leftOut).getByRole('button', { name: 'Not imported' })).toBeInTheDocument()
@@ -483,7 +483,8 @@ describe('ToursSidebar', () => {
fireEvent.click(screen.getByRole('button', { name: 'Import 2 tours' }))
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported'))
expect(pinsCall).not.toHaveBeenCalled()
expect(pinsCall).toHaveBeenCalledWith(1, file, { points: true, paths: false })
expect(toast.success).toHaveBeenCalledWith('3 places added')
vi.restoreAllMocks()
})
@@ -19,7 +19,6 @@ import {
X,
} from 'lucide-react';
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
@@ -307,10 +306,9 @@ function decidedLine(
decision: ObstacleDecisionRecord,
memberName: (id: number | null) => string | null,
t: Translate,
locale: string,
dateFormat: string
locale: string
): string {
const date = formatCheckDate(decision.decided_at, locale, dateFormat);
const date = formatCheckDate(decision.decided_at, locale);
const name = memberName(decision.decided_by);
return name ? t('tours.paddle.check.decidedBy', { date, name }) : t('tours.paddle.check.decidedAt', { date });
}
@@ -332,11 +330,10 @@ function MetaLine({
onNext: (() => void) | null;
}) {
const { t, locale } = useTranslation();
const dateFormat = useDateFormat();
if (!decision && !onNext) return null;
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-content-faint" style={fs(10.5, 'body')}>
{decision && <span className="min-w-0">{decidedLine(decision, memberName, t, locale, dateFormat)}</span>}
{decision && <span className="min-w-0">{decidedLine(decision, memberName, t, locale)}</span>}
{decision && clearable && (
<button type="button" onClick={onClear} disabled={disabled} className={QUIET}>
{t('tours.paddle.check.clear')}
@@ -389,7 +386,6 @@ export function ObstacleDecisionPanel({
onPlanPortage: (() => void) | null;
}) {
const { t, locale } = useTranslation();
const dateFormat = useDateFormat();
const locked = !canDecide || saving;
const gone = entry.status === 'gone';
const label = entry.name ?? t(`tours.paddle.obstacle.kind.${entry.kind}`);
@@ -425,9 +421,7 @@ export function ObstacleDecisionPanel({
<FigureBadge
testId="obstacle-on-site"
label={t('tours.paddle.check.onSite')}
value={
entry.local_check ? formatCheckDate(entry.local_check, locale, dateFormat) : t('tours.paddle.check.unknown')
}
value={entry.local_check ? formatCheckDate(entry.local_check, locale) : t('tours.paddle.check.unknown')}
/>
</div>
{entry.decision && <NoteBlock key={entry.key} note={entry.decision.note} disabled={locked} onNote={onNote} />}
@@ -7,7 +7,6 @@ import type {
TourObstacleEntry,
} from '@trek/shared';
import { RotateCcw } from 'lucide-react';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { TourDayRowPill } from '../TourDayRowFacts';
import { FigureBadge } from './FigureBadge';
@@ -78,9 +77,8 @@ export function PaddleCheckPill({ check }: { check: PaddleCheck }) {
*/
export function PaddleCheckDataLine({ check }: { check: PaddleCheck }) {
const { t, locale } = useTranslation();
const dateFormat = useDateFormat();
const word = checkDataWord(check.data, t);
const asOf = check.data_as_of ? formatCheckDate(check.data_as_of, locale, dateFormat) : null;
const asOf = check.data_as_of ? formatCheckDate(check.data_as_of, locale) : null;
const coverage =
check.data === 'covered' ? null : COVERAGE_INFO[check.data](t, asOf ?? t('tours.paddle.obstacles.dateUnknown'));
return (
@@ -105,7 +103,7 @@ export function PaddleCheckDataLine({ check }: { check: PaddleCheck }) {
<FigureBadge
testId="paddle-check-read"
label={t('tours.paddle.check.readDate')}
value={formatCheckDate(check.read_at, locale, dateFormat)}
value={formatCheckDate(check.read_at, locale)}
/>
) : null}
</div>
@@ -8,7 +8,6 @@ import type {
} from '@trek/shared';
import { AlertTriangle, CloudOff, LifeBuoy, ListChecks, Loader2, PencilLine, Save, WifiOff } from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
@@ -82,9 +81,8 @@ function rowKey(row: ChecklistRow, index: number): string {
/** The sentence the checklist's state calls for. */
function CheckNoticeLine({ notice, evaluatedAt }: { notice: CheckNotice; evaluatedAt: string | null }) {
const { t, locale } = useTranslation();
const dateFormat = useDateFormat();
const { textKey, icon, role } = NOTICES[notice];
const time = evaluatedAt ? formatCheckDate(evaluatedAt, locale, dateFormat) : '';
const time = evaluatedAt ? formatCheckDate(evaluatedAt, locale) : '';
return (
<TourNotice role={role} icon={icon}>
{t(textKey, { time })}
@@ -313,12 +311,11 @@ function EarlierDecisions({
/** A route without rows: nothing known where the data covers it, no data where there is none; else the data line speaks. */
function EmptyLine({ summary }: { summary: PaddleCheck | null }) {
const { t, locale } = useTranslation();
const dateFormat = useDateFormat();
let text: string | null = null;
if (summary?.data === 'none') text = t('tours.paddle.obstacles.none');
else if (summary?.data === 'covered') {
const date = summary.data_as_of
? formatCheckDate(summary.data_as_of, locale, dateFormat)
? formatCheckDate(summary.data_as_of, locale)
: t('tours.paddle.obstacles.dateUnknown');
text = t('tours.paddle.obstacles.coveredEmpty', { date });
}
@@ -1,10 +1,8 @@
import { ExternalLink, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import type { DistanceUnit } from '../../../types';
import { formatDisplayDate } from '../../../utils/dateFormat';
import { formatDistance } from '../../../utils/units';
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
import { SoftPill } from '../../Planner/planParts';
@@ -88,10 +86,7 @@ function AccessAndFacts({ detail, unit }: { detail: PaddleObstacleDetail; unit:
/** The way to the source and how current it is; a record in the data is no check on site. */
function SourceLines({ detail }: { detail: PaddleObstacleDetail }) {
const { t, language } = useTranslation();
const dateFormat = useDateFormat();
const date = detail.dataAsOf
? formatDisplayDate(new Date(detail.dataAsOf), language, { dateStyle: 'medium' }, dateFormat)
: null;
const date = detail.dataAsOf ? new Date(detail.dataAsOf).toLocaleDateString(language, { dateStyle: 'medium' }) : null;
return (
<>
<a
@@ -1,23 +1,17 @@
import { compareCodeUnits } from '@trek/shared';
import { AlertTriangle, Info, Loader2, WifiOff } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { usePaddleObstacleStore } from '../../../store/paddleObstacleStore';
import { formatDisplayDate } from '../../../utils/dateFormat';
import { TourNotice } from '../tourParts';
/** The oldest data date among the regions, as a short local date; the route is only as current as its oldest part. */
function dataDate(
regions: readonly { source_timestamp?: string | null }[],
language: string,
dateFormat: string
): string | null {
function dataDate(regions: readonly { source_timestamp?: string | null }[], language: string): string | null {
const times = regions
.map((region) => region.source_timestamp)
.filter((time): time is string => !!time)
.sort(compareCodeUnits);
return times[0] ? formatDisplayDate(new Date(times[0]), language, { dateStyle: 'medium' }, dateFormat) : null;
return times[0] ? new Date(times[0]).toLocaleDateString(language, { dateStyle: 'medium' }) : null;
}
/**
@@ -29,7 +23,6 @@ function dataDate(
*/
export function PaddleObstacleNotice() {
const { t, language } = useTranslation();
const dateFormat = useDateFormat();
const { status, coverage, related, regions, truncated } = usePaddleObstacleStore(
useShallow((state) => ({
status: state.status,
@@ -50,7 +43,7 @@ export function PaddleObstacleNotice() {
if (status === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.paddle.obstacles.offline')}</TourNotice>;
if (status === 'error') return <TourNotice icon={warn}>{t('tours.paddle.obstacles.error')}</TourNotice>;
const date = dataDate(regions, language, dateFormat) ?? t('tours.paddle.obstacles.dateUnknown');
const date = dataDate(regions, language) ?? t('tours.paddle.obstacles.dateUnknown');
if (coverage === 'none') return <TourNotice icon={warn}>{t('tours.paddle.obstacles.none')}</TourNotice>;
if (coverage === 'partial')
return <TourNotice icon={warn}>{t('tours.paddle.obstacles.partial', { date })}</TourNotice>;
@@ -1,4 +1,4 @@
// PCM-001 to PCM-020: the check bench of a paddle tour in one pill, how the dates of the checklist read, where the
// PCM-001 to PCM-019: the check bench of a paddle tour in one pill, how the dates of the checklist read, where the
// camera goes for an entry and the order the checklist lists its rows in (PAD-06, PAD-09).
import { Ban, CircleDashed, ListChecks } from 'lucide-react';
import { describe, expect, it } from 'vitest';
@@ -146,13 +146,6 @@ describe('formatCheckDate', () => {
expect(formatCheckDate('', 'en')).toBe('');
expect(formatCheckDate('2026-05', 'not a language')).toBe('2026-05');
});
it('PCM-020: a day takes the chosen date format where the language writes it in numbers, a month stays named', () => {
expect(formatCheckDate('2026-10-01', 'de', 'YYYY-MM-DD')).toBe('2026-10-01');
expect(formatCheckDate('2026-10-01', 'de', 'auto')).toBe(formatCheckDate('2026-10-01', 'de'));
expect(formatCheckDate('2026-10-01', 'en', 'YYYY-MM-DD')).toMatch(/Oct.*1.*2026/);
expect(formatCheckDate('2026-05', 'de', 'YYYY-MM-DD')).toBe(formatCheckDate('2026-05', 'de'));
});
});
describe('focusPointsOf', () => {
@@ -7,7 +7,6 @@ import {
type TourOwnEntry,
} from '@trek/shared';
import { Ban, CircleDashed, ListChecks, type LucideIcon } from 'lucide-react';
import { formatDisplayDate } from '../../../utils/dateFormat';
type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -53,11 +52,8 @@ export function paddleCheckFact(check: PaddleCheck, t: Translate): { icon: Lucid
const MONTH = /^(\d{4})-(\d{2})$/;
/**
* A date of the checklist in the user's locale: a bare `YYYY-MM` (an OSM check date) reads as month and year.
* `format` is the user's date format, which the day takes where the language writes it in numbers.
*/
export function formatCheckDate(value: string, locale: string, format?: string | null): string {
/** A date of the checklist in the user's locale: a bare `YYYY-MM` (an OSM check date) reads as month and year. */
export function formatCheckDate(value: string, locale: string): string {
const month = MONTH.exec(value);
try {
if (month) {
@@ -68,7 +64,7 @@ export function formatCheckDate(value: string, locale: string, format?: string |
if (Number.isNaN(date.getTime())) return value;
// A bare day is a calendar day, not an instant in the user's zone.
const timeZone = /^\d{4}-\d{2}-\d{2}$/.test(value) ? 'UTC' : undefined;
return formatDisplayDate(date, locale, { dateStyle: 'medium', timeZone }, format);
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone }).format(date);
} catch {
return value;
}
@@ -131,31 +131,4 @@ describe('useTourGpxImport with points beside the lines (#2969)', () => {
expect(addToast).toHaveBeenCalledWith('tours.import.pointsFailedIn', 'error', undefined);
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
});
it('says so when every line and every point is already in the trip, instead of closing without a word', async () => {
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 0, skipped: 3 });
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 0 });
const { result } = await previewed(() => ({
tracks: [{ ...line, duplicate: true }],
left_out: { points: 3, areas: 0, other: 0 },
}));
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
expect(tourRepo.importGpx).not.toHaveBeenCalled();
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith('tours.import.allSkipped', 'warning', undefined);
expect(result.current.preview).toBeNull();
});
it('names the points left out as known beside the places that did come in', async () => {
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 1, skipped: 2 });
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 2 });
const { result } = await previewed((picked) => ({
tracks: [{ ...line, duplicate: true }],
left_out: { points: picked === route ? 3 : 2, areas: 0, other: 0 },
}));
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
expect(addToast).toHaveBeenCalledWith('tours.import.pointsImported', 'success', undefined);
expect(addToast).toHaveBeenCalledWith('tours.import.pointsSkipped', 'warning', undefined);
expect(addToast).not.toHaveBeenCalledWith('tours.import.allSkipped', 'warning', undefined);
});
});
+10 -26
View File
@@ -78,33 +78,22 @@ async function importTours(
return sum;
}
interface PointsImported {
places: number;
/** Points the preview counted that did not come in, almost always because the trip already holds them. */
skipped: number;
}
/** The points of each file that holds any, added as places (#2969); a file that fails is handed to `onError`. */
async function importPoints(
tripId: number,
files: readonly GpxPreviewFile[],
onError: (file: File) => void
): Promise<PointsImported> {
const sum: PointsImported = { places: 0, skipped: 0 };
): Promise<number> {
let places = 0;
for (const { file, leftOut } of files) {
if (!leftOut?.points) continue;
try {
const added = await tourRepo.importFilePoints(tripId, file);
sum.places += added;
// Measured against the preview's count rather than the import's own
// skipped figure: the map import counts paths it was told to leave out
// as skipped too, the GPX import does not.
sum.skipped += Math.max(0, leftOut.points - added);
places += await tourRepo.importFilePoints(tripId, file);
} catch {
onError(file);
}
}
return sum;
return places;
}
interface TourGpxImportOptions {
@@ -191,12 +180,11 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported,
setImporting(true);
try {
const { tours: imported, skipped, caution } = await importTours(tripId, preview, fields, fileError);
const points = extras.points
const places = extras.points
? await importPoints(tripId, preview, (file) =>
toast.error(t('tours.import.pointsFailedIn', { name: file.name }))
)
: { places: 0, skipped: 0 };
const places = points.places;
: 0;
setPreview(null);
if (places > 0) {
if (imported.length === 0) await refreshPlaces(tripId);
@@ -212,14 +200,10 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported,
if (caution) toast.info(t('tours.import.caution'));
onImported?.(imported);
}
// Nothing came in although something was sent: one warning, so the
// dialog never closes on a file the trip already holds without a word.
const addedAny = imported.length > 0 || places > 0;
if (!addedAny && (skipped > 0 || points.skipped > 0)) {
toast.warning(t('tours.import.allSkipped'));
} else {
if (skipped > 0) toast.warning(t('tours.import.skipped', { count: skipped }));
if (points.skipped > 0) toast.warning(t('tours.import.pointsSkipped'));
if (skipped > 0) {
toast.warning(
imported.length > 0 ? t('tours.import.skipped', { count: skipped }) : t('tours.import.allSkipped')
);
}
} finally {
setImporting(false);
+1 -12
View File
@@ -1,4 +1,4 @@
// FE-W4TIP-001 to FE-W4TIP-018
// FE-W4TIP-001 to FE-W4TIP-017
import { createRef, useRef } from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
@@ -213,14 +213,3 @@ describe('Tooltip — with a shortcut', () => {
expect(screen.getByRole('tooltip').querySelector('kbd')).toBeNull()
})
})
describe('Tooltip — with content', () => {
it('FE-W4TIP-018: shows the content in place of the label, which still opens it', () => {
render(<Tooltip label="brought by Anna, Ben" content={<ul><li>Anna</li><li>Ben</li></ul>}><button>x</button></Tooltip>)
hover(screen.getByRole('button'))
const tip = screen.getByRole('tooltip')
expect(tip.querySelectorAll('li')).toHaveLength(2)
expect(tip).not.toHaveTextContent('brought by')
})
})
+5 -10
View File
@@ -11,15 +11,10 @@ interface TooltipProps {
disabled?: boolean
/** The keys that do the same, shown beside the label: ["⌘", "K"]. */
shortcut?: readonly string[]
/**
* Shown in place of the label when the tip holds more than words, such as a list of
* people with their pictures. `label` still names it and decides whether it opens.
*/
content?: React.ReactNode
children: React.ReactElement
}
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, shortcut, content, children }: TooltipProps) {
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, shortcut, children }: TooltipProps) {
const [open, setOpen] = useState(false)
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
const triggerRef = useRef<HTMLElement | null>(null)
@@ -98,8 +93,8 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, sh
zIndex: 100000,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
fontWeight: 500,
padding: content ? '8px 10px' : '5px 10px',
borderRadius: content ? 10 : 8,
padding: '5px 10px',
borderRadius: 8,
// A label of several lines (the paddle check bench) breaks at its lines and wraps within the window.
whiteSpace: label.includes('\n') ? 'pre-line' : 'nowrap',
maxWidth: label.includes('\n') ? 'min(22rem, calc(100vw - 12px))' : undefined,
@@ -108,12 +103,12 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, sh
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
}}
>
{content ?? (shortcut && shortcut.length > 0 ? (
{shortcut && shortcut.length > 0 ? (
<span className="inline-flex items-center gap-2">
{label}
<Kbd keys={shortcut} />
</span>
) : label)}
) : label}
</div>,
document.body,
)}
@@ -1,68 +0,0 @@
import { render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useMeasuredHeight, useReportedHeight } from './useReportedHeight';
function Card({ onHeight, open = true }: { onHeight?: (height: number) => void; open?: boolean }) {
const ref = useReportedHeight(onHeight);
return open ? <div ref={ref}>card</div> : null;
}
const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight');
afterEach(() => {
if (original) Object.defineProperty(HTMLElement.prototype, 'offsetHeight', original);
vi.unstubAllGlobals();
});
describe('useReportedHeight', () => {
it('REPORTED-HEIGHT-001: reports the height on mount and 0 once the element is gone', () => {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 412 });
const onHeight = vi.fn();
const { rerender } = render(<Card onHeight={onHeight} />);
expect(onHeight).toHaveBeenLastCalledWith(412);
rerender(<Card onHeight={onHeight} open={false} />);
expect(onHeight).toHaveBeenLastCalledWith(0);
});
it('REPORTED-HEIGHT-002: reports again whenever the element resizes', () => {
let height = 300;
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => height });
const observed: { resize?: () => void } = {};
const disconnect = vi.fn();
vi.stubGlobal(
'ResizeObserver',
class {
constructor(cb: () => void) {
observed.resize = cb;
}
observe() {}
disconnect = disconnect;
}
);
const onHeight = vi.fn();
const { unmount } = render(<Card onHeight={onHeight} />);
height = 820;
expect(observed.resize).toBeTypeOf('function');
observed.resize?.();
expect(onHeight).toHaveBeenLastCalledWith(820);
unmount();
expect(disconnect).toHaveBeenCalled();
});
it('REPORTED-HEIGHT-003: does nothing without a listener', () => {
expect(() => render(<Card />)).not.toThrow();
});
});
describe('useMeasuredHeight', () => {
it('REPORTED-HEIGHT-004: holds the measured height as state', () => {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 512 });
function Probe() {
const { height, measure } = useMeasuredHeight();
return <div ref={measure} data-height={height} />;
}
const { container } = render(<Probe />);
expect(container.firstElementChild?.getAttribute('data-height')).toBe('512');
});
});
-42
View File
@@ -1,42 +0,0 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
/**
* A callback ref that reports its element's rendered height whenever it changes, and 0
* once the element is gone. For a card floating over something that has to keep clear of
* it, the way the map keeps the selected pin above the place card (#2991).
*/
export function useReportedHeight(onHeight: ((height: number) => void) | undefined) {
const observer = useRef<ResizeObserver | null>(null);
const report = useRef(onHeight);
useLayoutEffect(() => {
report.current = onHeight;
}, [onHeight]);
const ref = useCallback((el: HTMLElement | null) => {
observer.current?.disconnect();
observer.current = null;
if (!el) {
report.current?.(0);
return;
}
const publish = () => report.current?.(el.offsetHeight);
publish();
if (typeof ResizeObserver !== 'undefined') {
observer.current = new ResizeObserver(publish);
observer.current.observe(el);
}
}, []);
useEffect(() => () => observer.current?.disconnect(), []);
return ref;
}
/**
* The element's height as state, and `measure`, the callback ref that reads it: hand
* `measure` to the element as its `ref`.
*/
export function useMeasuredHeight() {
const [height, setHeight] = useState(0);
const measure = useReportedHeight(setHeight);
return { height, measure };
}
@@ -23,9 +23,7 @@ import {
regionStatusTone,
} from '../../../components/Admin/paddle/paddleRegionsModel';
import { usePaddleRegionsAdmin } from '../../../components/Admin/paddle/usePaddleRegionsAdmin';
import { useDateFormat } from '../../../hooks/useDateFormat';
import { useTranslation } from '../../../i18n';
import { formatDisplayDate } from '../../../utils/dateFormat';
import MBadge from '../../components/MBadge';
import MChip from '../../components/MChip';
import MConfirmSheet from '../settings/MConfirmSheet';
@@ -113,7 +111,6 @@ function AutoLoad({ admin }: { admin: Admin }) {
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
const { t, language } = useTranslation();
const dateFormat = useDateFormat();
const busy = admin.busyId === request.id;
return (
<div data-testid="paddle-region-request" className={`px-3 py-2.5 ${busy ? 'opacity-60' : ''}`}>
@@ -137,7 +134,7 @@ function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: A
<MBadge caps={false}>{t('admin.paddleRegions.requests.by', { user: request.requested_by })}</MBadge>
)}
<MBadge caps={false}>
{formatDisplayDate(new Date(request.requested_at), language, { dateStyle: 'medium' }, dateFormat)}
{new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' })}
</MBadge>
</div>
<div className="mt-2 flex justify-end gap-1.5">
@@ -272,14 +269,13 @@ function CatalogSearch({ admin }: { admin: Admin }) {
/** What a region's read stands at: its status, where it came from, its objects, its date and its water. */
function RegionBadges({ region }: { region: PaddleRegion }) {
const { t, language } = useTranslation();
const dateFormat = useDateFormat();
const percent = progressPercent(region);
const status =
region.status === 'importing' && region.progress
? t(`admin.paddleRegions.phase.${region.progress.phase}`)
: t(`admin.paddleRegions.status.${region.status}`);
const date = region.source_timestamp
? formatDisplayDate(new Date(region.source_timestamp), language, { dateStyle: 'medium' }, dateFormat)
? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' })
: null;
const km = region.network
? new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(region.network.water_km)
@@ -192,8 +192,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
</MAdminCard>
{/* API keys, place search and routing: the operator's on a managed install,
same as the desktop tab, so the whole card is left out. Unlike the desktop
tab, the phone has no share place details section for a managed install. */}
same as the desktop tab. Share place details has its own section there. */}
{!managed && (
<MAdminCard>
<MAdminCardHead title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')} />
@@ -205,6 +204,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
laptop. */}
<MTrekApiBlock />
{/* The keys belong to the operator on a managed install, same as on the
desktop tab; the provider choice below stays the admin's. */}
{!managed && (<>
<MProviderBlock title={t('admin.mapsKey')} badge={t('admin.googleCaveat.badge')} tone="caution">
{/* Said before the field, not after it: somebody about to paste a key
should read this while deciding, not once they already have. */}
@@ -323,6 +325,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
{...keyInputProps('unsplash')}
/>
</MProviderBlock>
</>)}
<MAdminField label={t('admin.placesProvider.title')} hint={t('admin.placesProvider.subtitle')}>
{/* The app's own picker rather than a bare <select>: a native one is
@@ -375,9 +378,11 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
/>
</div>
<MAdminButton busy={savingKeys} onClick={handleSaveApiKeys}>
{t('common.save')}
</MAdminButton>
{!managed && (
<MAdminButton busy={savingKeys} onClick={handleSaveApiKeys}>
{t('common.save')}
</MAdminButton>
)}
</div>
</MAdminCard>
)}
@@ -103,7 +103,7 @@ export default function MImportSheet({ planner, open, onClose }: MImportSheetPro
)}
{step === 'list' && !fileStep && <ImpListStep planner={planner} onBack={() => setStep('menu')} onDone={onClose} />}
</MSheet>
<TourGpxPreviewDialog gpxImport={fileImport.tourImport} offerPoints />
<TourGpxPreviewDialog gpxImport={fileImport.tourImport} />
</>
)
}
@@ -299,19 +299,6 @@ function PollCardRow({ poll, canEdit, currentUserId, t, onVote, onClosePoll, onD
)
}
const BARE_DAY = /^(\d{4})-(\d{2})-(\d{2})$/
/**
* The instant a deadline from the picker stands for. With its time cleared the picker
* hands back the day alone, and `new Date('2030-08-01')` reads that as UTC midnight,
* hours off the reader's own day; a day without a time runs to its end on their clock.
*/
function deadlineInstant(value: string): string {
const day = BARE_DAY.exec(value)
const date = day ? new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3]), 23, 59) : new Date(value)
return date.toISOString()
}
function PollFormSheet({ open, onClose, onSubmit, t }: {
open: boolean
onClose: () => void
@@ -343,7 +330,7 @@ function PollFormSheet({ open, onClose, onSubmit, t }: {
question: question.trim(),
options: trimmedOptions,
multipleChoice,
deadline: deadline ? deadlineInstant(deadline) : null,
deadline: deadline ? new Date(deadline).toISOString() : null,
})
onClose()
} catch {
+10 -10
View File
@@ -294,24 +294,24 @@ function CollectionsPageDesktop(): React.ReactElement {
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
<div className="col-toolbar-spacer" />
{c.canEdit && isRealList && (
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn act" title={t('collections.labels.assign')}>
<Tags size={14} /> <span className="col-selbar-txt">{t('collections.labels.assign')}</span>
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<Tags size={14} /> {t('collections.labels.assign')}
</button>
)}
{c.canDelete && (
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn act" title={t('collections.moveToList')}>
<FolderInput size={14} /> <span className="col-selbar-txt">{t('collections.moveToList')}</span>
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<FolderInput size={14} /> {t('collections.moveToList')}
</button>
)}
<button type="button" onClick={() => c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className="col-selbar-btn act" title={t('collections.duplicateToList')}>
<CopyPlus size={14} /> <span className="col-selbar-txt">{t('collections.duplicateToList')}</span>
<button type="button" onClick={() => c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<CopyPlus size={14} /> {t('collections.duplicateToList')}
</button>
<button type="button" onClick={c.openCopyForSelection} disabled={c.selectedIds.length === 0} className="col-selbar-btn act" title={t('collections.copyToTrip')}>
<Copy size={14} /> <span className="col-selbar-txt">{t('collections.copyToTrip')}</span>
<button type="button" onClick={c.openCopyForSelection} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<Copy size={14} /> {t('collections.copyToTrip')}
</button>
{c.canDelete && (
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} className="col-selbar-btn act danger" title={t('common.delete')}>
<Trash2 size={14} /> <span className="col-selbar-txt">{t('common.delete')}</span>
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} className="col-selbar-btn danger">
<Trash2 size={14} /> {t('common.delete')}
</button>
)}
<button type="button" onClick={() => c.setSelectMode(false)} className="col-selbar-btn" aria-label={t('common.cancel')}>
+2 -2
View File
@@ -280,7 +280,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
dawarichEnabled={dawarichEnabled} dawarichTrail={dawarichTrail} dawarichHiddenDates={dawarichHiddenDates} dawarichTrailShown={dawarichTrailShown} toggleDawarichTrail={toggleDawarichTrail}
mobileSidebarOpen={mobileSidebarOpen} showPlaceForm={showPlaceForm} showMembersModal={showMembersModal} showReservationModal={showReservationModal}
poi={poi} tourMap={tourMap} leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx} mapInsetLeft={mapInsetLeft} mapInsetRight={mapInsetRight}
glMap={glMap} setGlMap={setGlMap} poiPillEnabled={poiPillEnabled} mapPois={mapPois} distanceUnit={distanceUnit} inspectorHeight={pageState.inspectorCard.height}
glMap={glMap} setGlMap={setGlMap} poiPillEnabled={poiPillEnabled} mapPois={mapPois} distanceUnit={distanceUnit}
/>
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
@@ -547,7 +547,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onDelete={() => handleDeletePlace(selectedPlace.id)}
leftWidth={isMobile ? 0 : leftPanelPx}
rightWidth={isMobile ? 0 : rightPanelPx}
clearMapPill cardRef={pageState.inspectorCard.measure}
clearMapPill
/>
)}
+11 -9
View File
@@ -390,11 +390,12 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
as it is changed. */}
{/* On a managed install place search and routing are the operator's: the
services, their allowance and the providers behind them come with the
instance, so the whole card is left out. Share place details stays the
admin's call and is rendered on its own just above (PlaceContribPanel). */}
instance. Share place details stays the admin's call and has a tab of
its own there (AdminPage). */}
{!managed && (
<SettingsCard icon={Compass} title={t('admin.servicesCard.title')} hint={t('admin.servicesCard.hint')}>
<TrekApiCard t={t} />
<TrekApiCard t={t} managed={managed} />
{!managed && (<>
{/* Transit Backend (#1699) — Transitous has no GTFS for much of Asia,
so an install can point transit search at Google instead, on the
same key. Falls back to Transitous when no key resolves. A block of
@@ -447,9 +448,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
<Callout>{t('admin.transitProvider.personalKeyWarning')}</Callout>
)}
</ProviderBlock>
{/* Which keyed provider answers place search. Never shown on a managed
install: places_provider is operator-locked there (MANAGED_LOCKED_SETTING_KEYS
in server managed.ts), so a pick would be dropped on save. */}
</>)}
{/* Which keyed provider answers place search. Outside the managed guard
on purpose: the operator owns the credentials, but what their users
search against is still the admin's call (see server managed.ts). */}
<ProviderBlock title={t('admin.placesProvider.title')}>
<SettingsHint>{t('admin.placesProvider.subtitle')}</SettingsHint>
<div className={savingPlacesProvider ? 'opacity-60' : undefined}>
@@ -502,9 +504,9 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
)}
{/* API Keys: only the keyed providers, Google, Unsplash and Amap. They are
the operator's on a managed install, so the card is not shown there
(server managed.ts), and neither is the provider pick in the card
above. The per-place Google switches sit under the key
the operator's on a managed install, so the card is not shown there;
which provider answers search stays the admin's call in the card above
(server managed.ts). The per-place Google switches sit under the key
because they are set once and then never touched. */}
{!managed && (
<SettingsCard icon={KeyRound} title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')}>
@@ -47,8 +47,6 @@ interface StagePageLocals {
poiPillEnabled: boolean
mapPois: Poi[]
distanceUnit: Settings['distance_unit']
/** The open place card's measured height, so the selected pin is panned above it. */
inspectorHeight?: number
}
export interface PlanMapStageProps
@@ -75,7 +73,7 @@ export function PlanMapStage({
dawarichEnabled, dawarichTrail, dawarichHiddenDates, dawarichTrailShown, toggleDawarichTrail,
mobileSidebarOpen, showPlaceForm, showMembersModal, showReservationModal,
poi, tourMap, leftPanelPx, rightPanelPx, mapInsetLeft, mapInsetRight,
glMap, setGlMap, poiPillEnabled, mapPois, distanceUnit, inspectorHeight = 0,
glMap, setGlMap, poiPillEnabled, mapPois, distanceUnit,
}: Readonly<PlanMapStageProps>): ReactElement {
// ACT-02: a tour compared with its actual track lays the track into the day's lines, dotted.
const compared = useActivityCompareMap({
@@ -117,7 +115,6 @@ export function PlanMapStage({
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
hasInspector={!!selectedPlace}
inspectorHeight={selectedPlace ? inspectorHeight : 0}
hasDayDetail={!!showDayDetail && !selectedPlace}
reservations={reservations}
showReservationStats={true}
@@ -8,7 +8,6 @@ import { useTrackPortageParts } from '../../components/Tours/paddle/useTrackPort
import { usePaddleObstacleSync } from '../../components/Tours/paddle/usePaddleObstacleSync'
import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/Tours/tourPresentation'
import { usePlanMapViewport } from '../../hooks/usePlanMapViewport'
import { useMeasuredHeight } from '../../hooks/useReportedHeight'
import { usePaddleObstacleStore } from '../../store/paddleObstacleStore'
import { useHighlightedRoute } from '../../components/Tours/bike/useHighlightedRoute'
import { useLegVariantLines } from '../../components/Tours/planner/legVariantPaint'
@@ -102,8 +101,6 @@ export function useTripPlannerPage() {
}, [activeTab, closeGpxTour])
const poi = usePoiExplore()
// The place card's height over the desktop map, so the selection pans its pin above it (#2991).
const inspectorCard = useMeasuredHeight()
const poiViewportChangeRef = useRef(poi.onViewportChange)
poiViewportChangeRef.current = poi.onViewportChange
// The place search looks where the plan map looks, and only while it is on screen.
@@ -166,6 +163,5 @@ export function useTripPlannerPage() {
// The day plan's tours: which places are tours, their planned times and where their stages run (MDT-02).
dayPlanTours: { tourPlaceIds: planner.tourPlaceIds, plannedTourDurations, tourStageStops: planner.tourStageStops },
placeForm: { close: closePlaceForm, ...placeFormContext(planner.editingPlace, planner.assignments, tours) },
inspectorCard,
}
}
+4 -71
View File
@@ -215,13 +215,6 @@
display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; padding: 10px 14px;
border-radius: var(--r-md); background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--sh-sm);
}
.trek-dash .col-selbar { container-type: inline-size; }
/* Too narrow for every action's words: the actions keep their icon and say what they do
in their tooltip, so the bar stays one line instead of stranding "Delete" below. */
@container (max-width: 1120px) {
.trek-dash .col-selbar-btn.act { padding-inline: 9px; }
.trek-dash .col-selbar-btn.act .col-selbar-txt { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
}
.trek-dash .col-selbar .lbl { font-size: 13px; font-weight: 600; color: var(--ink); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-selbar-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 9px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); transition: background .12s, color .12s; }
.trek-dash .col-selbar-btn:hover { background: var(--surface-2); color: var(--ink); }
@@ -261,8 +254,6 @@
/* ----------------- list view ----------------- */
.trek-dash .col-listview { display: flex; flex-direction: column; gap: 7px; }
/* A row: tile, name and address, then whatever sits at its end. A kept tour keeps one
line: tile, name, its end group. */
.trek-dash .col-lrow {
display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: 14px; align-items: center;
padding: 10px 14px; border-radius: var(--r-md);
@@ -281,40 +272,8 @@
/* The pin before the address must not be squeezed by a long address. */
.trek-dash .col-lrow .li .s svg { flex-shrink: 0; }
.trek-dash .col-lrow-end { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
/* A place row (#2981). Its labels and category used to sit in an end group that could
not shrink, so with two or three long labels the name got one letter or none. Now the
name keeps the room it needs and the labels give way: on a wide list they share the
line and end in an ellipsis; on a narrow one (below 760px) the row has two fixed lines,
name and status on top, labels and category below, still on one line. */
.trek-dash .col-listview { container-type: inline-size; }
.trek-dash .col-lrow-place {
grid-template-columns: 40px minmax(12rem, max-content) minmax(0, 1fr) auto;
grid-template-areas: "tile main tags status";
}
.trek-dash .col-lrow-place > :first-child { grid-area: tile; }
.trek-dash .col-lrow-place > .li { grid-area: main; }
.trek-dash .col-lrow-tags { grid-area: tags; position: relative; display: flex; justify-content: flex-end; align-items: center; gap: 8px; min-width: 0; overflow: hidden; }
/* A label the row has no room for stays in it, out of sight, so it can be measured. */
.trek-dash .col-lrow-tags > .is-over { position: absolute; inset-inline-start: 0; visibility: hidden; pointer-events: none; }
.trek-dash .col-lrow-tags > * { flex-shrink: 0; }
.trek-dash .col-lrow-tags > .col-lrow-label, .trek-dash .col-lrow-tags > .col-lrow-cat { flex-shrink: 1; min-width: 0; }
.trek-dash .col-lrow-more { display: inline-flex; align-items: center; flex-shrink: 0; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; color: var(--ink-3); background: var(--surface-2); cursor: default; }
.trek-dash .col-lrow-status { grid-area: status; display: flex; }
@container (max-width: 760px) {
.trek-dash .col-lrow-place {
grid-template-columns: 40px minmax(0, 1fr) auto;
grid-template-areas: "tile main status" "tile tags tags";
row-gap: 0; align-items: start;
}
.trek-dash .col-lrow-place > :first-child { align-self: center; }
.trek-dash .col-lrow-tags { margin-top: 8px; justify-content: flex-start; }
.trek-dash .col-lrow-tags .col-lrow-div { display: none; }
.trek-dash .col-lrow-tags > .col-lrow-label, .trek-dash .col-lrow-tags > .col-lrow-cat { max-width: 170px; }
}
.trek-dash .col-lrow-cat { display: inline-flex; align-items: center; gap: 5px; max-width: 130px; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--cat, var(--accent)); background: color-mix(in oklch, var(--cat, var(--accent)) 14%, transparent); }
.trek-dash .col-lrow-cat svg { flex-shrink: 0; }
.trek-dash .col-lrow-txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.trek-dash .col-lrow-div { width: 1px; height: 18px; flex-shrink: 0; background: var(--line-2); }
/* A kept tour (LIB-03) is a row of the same list: its grade tile stands where
@@ -359,7 +318,7 @@
/* On the map the tray joins the floating controls, so it takes their glass,
their radius and their shadow instead of the row's flat surface. */
.trek-dash .col-labelfilter.on-map {
pointer-events: auto; max-width: 100%; flex-wrap: nowrap; overflow: hidden;
pointer-events: auto; max-width: min(52ch, 100%); flex-wrap: nowrap; overflow-x: auto;
border-radius: 11px; border-color: var(--glass-border); box-shadow: var(--sh-md);
background: color-mix(in oklch, var(--surface) 92%, transparent);
backdrop-filter: blur(8px) saturate(1.2); -webkit-backdrop-filter: blur(8px) saturate(1.2);
@@ -367,17 +326,6 @@
}
.trek-dash .col-labelfilter.on-map::-webkit-scrollbar { display: none; }
.trek-dash .col-labelfilter.on-map .col-labelchip { flex-shrink: 0; }
/* The strip takes the room the map's top bar leaves between its toggle and the search.
When its labels need more, it folds into one button with the labels as a list; the
hidden copy measures what the labels need, so the strip unfolds once the room is back. */
.trek-dash .col-map-group:not(.right) { flex: 1 1 auto; min-width: 0; }
.trek-dash .col-labelfilter-slot { position: relative; display: flex; flex: 1 1 0; min-width: 0; }
.trek-dash .col-labelfilter-measure { position: absolute; top: 0; inset-inline-start: 0; width: max-content; max-width: none; visibility: hidden; pointer-events: none; }
.trek-dash .col-labelfilter-fold { gap: 7px; padding-inline: 10px 9px; font: inherit; font-size: 12.5px; font-weight: 600; color: var(--ink-2); cursor: pointer; }
.trek-dash .col-labelfilter-fold:hover, .trek-dash .col-labelfilter-fold.open { color: var(--ink); }
.trek-dash .col-labelfilter-active { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: var(--accent-text); background: var(--accent); }
.trek-dash .col-labelfilter-pop { pointer-events: auto; }
.trek-dash .col-labelfilter-manage { color: var(--ink-3); border-top: 1px solid var(--line); border-radius: 0 0 8px 8px; margin-top: 2px; }
.trek-dash .col-labelfilter.on-map .col-labelchip:hover { background: var(--surface-2); }
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 5px 9px; border-radius: 8px; font-size: 12.5px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: transparent; border: 1px solid transparent; transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-labelchip:hover { background: var(--surface); color: var(--ink); }
@@ -410,21 +358,7 @@
over the map are read as a single band when the page is scrolled to the top,
so they share a height and a top offset instead of each having their own. */
.trek-dash { --col-ctl-h: 38px; }
/* One line on desktop: the dropdowns shorten their labels instead of pushing the sort
control onto a second line. Phones wrap (see the 720px block). */
.trek-dash .col-filterbar { display: flex; flex-wrap: nowrap; align-items: center; gap: 8px; margin-bottom: 12px; min-width: 0; }
.trek-dash .col-filterbar > * { flex-shrink: 0; }
.trek-dash .col-filterbar > .col-filter { flex-shrink: 1; min-width: 0; }
.trek-dash .col-filterbar > .col-filter > .col-filter-btn { max-width: 100%; min-width: 0; }
.trek-dash .col-filterbar > .col-filter > .col-filter-btn > svg { flex-shrink: 0; }
.trek-dash .col-filterbar > .col-filter .col-filter-lbl { min-width: 0; }
/* Narrow: a dropdown left at its default ("All", "Default") keeps its icon and says its
value in its tooltip; one set to a value still shows it. */
.trek-dash .col-filterbar { container-type: inline-size; }
@container (max-width: 540px) {
.trek-dash .col-filter-btn.is-default .col-filter-lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.trek-dash .col-filter-btn.is-default { padding-inline: 9px; }
}
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; }
/* Beside the map, the row starts where the map's own top bar does. */
.trek-dash .col-split-list .col-filterbar { margin-top: 12px; }
.trek-dash .col-filter { position: relative; }
@@ -649,13 +583,12 @@
.trek-dash .col-drawer .col-rail-new { min-height: 44px; }
.trek-dash .col-drawer .col-rail-import { min-width: 44px; }
/* enlarge the interactive status badge's tap area without changing its look */
.trek-dash .col-lrow-status > [role="button"] { position: relative; }
.trek-dash .col-lrow-status > [role="button"]::after { content: ''; position: absolute; inset: -9px -6px; }
.trek-dash .col-lrow-end > [role="button"] { position: relative; }
.trek-dash .col-lrow-end > [role="button"]::after { content: ''; position: absolute; inset: -9px -6px; }
/* select action bar: break the bulk actions onto their own line instead of
stranding them behind a growing spacer */
.trek-dash .col-selbar .col-toolbar-spacer { flex-basis: 100%; height: 0; }
.trek-dash .col-filterbar { flex-wrap: wrap; }
}
/* ----------------- mobile rail drawer ----------------- */
+26 -25
View File
@@ -1,9 +1,10 @@
// FE-UTIL-DATEFMT-001 to FE-UTIL-DATEFMT-023
// FE-UTIL-DATEFMT-001 to FE-UTIL-DATEFMT-020
import { describe, expect, it } from 'vitest';
import {
dateFieldOrder,
dateFormatOptions,
dateLayout,
displayDateFormatter,
formatDisplayDate,
formatDisplayDateTime,
isNumericDate,
@@ -77,27 +78,13 @@ describe('dateFormat (#2971)', () => {
{ weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' },
{ day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' },
{ month: 'long', year: 'numeric', timeZone: 'UTC' },
{ dateStyle: 'long', timeZone: 'UTC' },
{ dateStyle: 'full', timeZone: 'UTC' },
{ dateStyle: 'medium', timeZone: 'UTC' },
];
for (const opts of named) {
expect(formatDisplayDate(NYE, 'de-DE', opts, 'YYYY-MM-DD')).toBe(NYE.toLocaleDateString('de-DE', opts));
}
});
it('FE-UTIL-DATEFMT-022: dateStyle medium follows the format where the language writes it in numbers', () => {
const opts: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeZone: 'UTC' };
// German "31.12.2026", Japanese "2026/12/31", Czech "31. 12. 2026", Korean "2026. 12. 31.".
for (const locale of ['de-DE', 'ja-JP', 'cs-CZ', 'ko-KR']) {
expect(formatDisplayDate(NYE, locale, opts, 'YYYY-MM-DD')).toBe('2026-12-31');
}
// English and French name the month, Chinese writes words between the numbers.
for (const locale of ['en-US', 'fr-FR', 'zh-CN']) {
expect(formatDisplayDate(NYE, locale, opts, 'YYYY-MM-DD')).toBe(NYE.toLocaleDateString(locale, opts));
}
expect(formatDisplayDate(NYE, 'de-DE', opts, 'auto')).toBe(NYE.toLocaleDateString('de-DE', opts));
});
it('FE-UTIL-DATEFMT-021: a weekday beside a numeric date keeps the language, the date takes the format', () => {
const opts: Intl.DateTimeFormatOptions = {
weekday: 'short',
@@ -170,12 +157,30 @@ describe('dateFormat (#2971)', () => {
};
expect(formatDisplayDateTime(NYE, 'en-US', named, 'YYYY-MM-DD')).toBe(NYE.toLocaleString('en-US', named));
});
});
it('FE-UTIL-DATEFMT-023: dateStyle medium with a time puts the numeric date in the format', () => {
const opts: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' };
const time = NYE.toLocaleTimeString('de-DE', { timeStyle: 'short', timeZone: 'UTC' });
expect(formatDisplayDateTime(NYE, 'de-DE', opts, 'DD/MM/YYYY')).toBe(`31/12/2026 ${time}`);
expect(formatDisplayDateTime(NYE, 'en-US', opts, 'DD/MM/YYYY')).toBe(NYE.toLocaleString('en-US', opts));
describe('displayDateFormatter', () => {
it('FE-UTIL-DATEFMT-015: auto behaves like Intl.DateTimeFormat', () => {
const intl = new Intl.DateTimeFormat('en-US', NUMERIC);
expect(displayDateFormatter('en-US', NUMERIC, 'auto').format(NYE)).toBe(intl.format(NYE));
expect(displayDateFormatter('en-US', NUMERIC).format(NYE.getTime())).toBe(intl.format(NYE));
});
it('FE-UTIL-DATEFMT-016: numeric dates follow the format, for Date and timestamp alike', () => {
const f = displayDateFormatter('en-US', NUMERIC, 'DD.MM.YYYY');
expect(f.format(NYE)).toBe('31.12.2026');
expect(f.format(NYE.getTime())).toBe('31.12.2026');
const withTime = displayDateFormatter('en-US', { ...NUMERIC, hour: '2-digit', minute: '2-digit' }, 'YYYY-MM-DD');
expect(withTime.format(NYE)).toBe(
`2026-12-31 ${NYE.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })}`
);
});
it('FE-UTIL-DATEFMT-017: a named month is left to Intl', () => {
const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', timeZone: 'UTC' };
expect(displayDateFormatter('de-DE', opts, 'YYYY-MM-DD').format(NYE)).toBe(
new Intl.DateTimeFormat('de-DE', opts).format(NYE)
);
});
});
@@ -201,10 +206,6 @@ describe('dateFormat (#2971)', () => {
expect(isNumericDate({ era: 'short' })).toBe(false);
expect(isNumericDate({ dateStyle: 'short' })).toBe(true);
expect(isNumericDate({ dateStyle: 'long' })).toBe(false);
expect(isNumericDate({ dateStyle: 'medium' }, 'de-DE')).toBe(true);
expect(isNumericDate({ dateStyle: 'medium' }, 'ja-JP')).toBe(true);
expect(isNumericDate({ dateStyle: 'medium' }, 'en-US')).toBe(false);
expect(isNumericDate({ dateStyle: 'long' }, 'de-DE')).toBe(false);
});
});
+31 -39
View File
@@ -35,53 +35,20 @@ export function dateLayout(format: string | null | undefined): DateLayout | null
return LAYOUTS[format as keyof typeof LAYOUTS] ?? null;
}
/** 31 December: no day of it reads as a month, so every example and probe of a layout is unambiguous. */
const SAMPLE = new Date(Date.UTC(2026, 11, 31));
const DATE_KEYS = ['day', 'month', 'year'] as const;
const TIME_KEYS = ['hour', 'minute', 'second', 'dayPeriod', 'fractionalSecondDigits'] as const;
/**
* Whether these options write the date in numbers only: no weekday, no month
* name, no era. `dateStyle: 'short'` is numeric in every language. The longer
* styles depend on the language: 'medium' names the month in English or French
* but is all numbers in German ("31.12.2026"), Czech or Japanese ("2026/12/31"),
* so those are asked of `Intl` for the given locale.
* name, no era. `dateStyle: 'short'` is numeric in every language; the longer
* styles name the month.
*/
export function isNumericDate(opts: Intl.DateTimeFormatOptions = {}, locale?: string): boolean {
export function isNumericDate(opts: Intl.DateTimeFormatOptions = {}): boolean {
if (opts.weekday || opts.era) return false;
if (opts.dateStyle) return opts.dateStyle === 'short' || isNumericStyle(opts.dateStyle, locale);
if (opts.dateStyle) return opts.dateStyle === 'short';
return !opts.month || opts.month === 'numeric' || opts.month === '2-digit';
}
const numericStyles = new Map<string, boolean>();
/**
* Whether a language writes this dateStyle as digits and punctuation alone. A
* month name, a weekday or a word between the numbers ("2026年12月31日") keeps
* it with the language, the same as a month name in explicit options would.
*/
function isNumericStyle(dateStyle: NonNullable<Intl.DateTimeFormatOptions['dateStyle']>, locale?: string): boolean {
const key = `${locale ?? ''}|${dateStyle}`;
let numeric = numericStyles.get(key);
if (numeric === undefined) {
// Separators may carry the bidi marks Arabic puts around them (\p{Cf}).
try {
numeric = new Intl.DateTimeFormat(locale, { dateStyle, timeZone: 'UTC' })
.formatToParts(SAMPLE)
.every((part) =>
part.type === 'literal'
? /^[\s\p{P}\p{Cf}]*$/u.test(part.value)
: (part.type === 'day' || part.type === 'month' || part.type === 'year') && /^\p{Nd}+$/u.test(part.value)
);
} catch {
numeric = false;
}
numericStyles.set(key, numeric);
}
return numeric;
}
const hasAny = (opts: Intl.DateTimeFormatOptions, keys: readonly string[]) =>
keys.some((key) => (opts as Record<string, unknown>)[key] !== undefined);
@@ -131,7 +98,7 @@ export function formatDisplayDate(
const weekday = date.toLocaleDateString(locale, { weekday: opts.weekday, timeZone: opts.timeZone });
return `${weekday}, ${numericDate(date, opts, layout)}`;
}
if (!isNumericDate(opts, locale)) return date.toLocaleDateString(locale, opts);
if (!isNumericDate(opts)) return date.toLocaleDateString(locale, opts);
return numericDate(date, opts, layout);
}
@@ -155,7 +122,7 @@ export function formatDisplayDateTime(
const layout = dateLayout(format);
const named = hasAny(opts, DATE_KEYS) || !!opts.dateStyle;
const timed = hasAny(opts, TIME_KEYS) || !!opts.timeStyle;
if (!layout || !isNumericDate(opts, locale) || Number.isNaN(date.getTime())) return date.toLocaleString(locale, opts);
if (!layout || !isNumericDate(opts) || Number.isNaN(date.getTime())) return date.toLocaleString(locale, opts);
if (!named && timed) return date.toLocaleString(locale, opts);
const day = numericDate(date, opts, layout);
if (named && !timed) return day;
@@ -163,6 +130,28 @@ export function formatDisplayDateTime(
return `${day} ${time}`;
}
/**
* A drop-in for `new Intl.DateTimeFormat(locale, opts)` where only `.format()`
* is used: numeric dates in the chosen format, the rest by `Intl`.
*/
export function displayDateFormatter(
locale: string | undefined,
opts: Intl.DateTimeFormatOptions = {},
format?: string | null
): { format: (date: Date | number) => string } {
const intl = new Intl.DateTimeFormat(locale, opts);
const layout = dateLayout(format);
if (!layout || !isNumericDate(opts)) return { format: (date) => intl.format(date) };
return {
format: (date) => {
const value = typeof date === 'number' ? new Date(date) : date;
return hasAny(opts, TIME_KEYS) || opts.timeStyle
? formatDisplayDateTime(value, locale, opts, format)
: formatDisplayDate(value, locale, opts, format);
},
};
}
/** The order the fields of a typed date are read in: the chosen format's, or the language's. */
export function dateFieldOrder(locale: string | undefined, format?: string | null): DateField[] {
const layout = dateLayout(format);
@@ -173,6 +162,9 @@ export function dateFieldOrder(locale: string | undefined, format?: string | nul
.map((part) => part.type as DateField);
}
/** 31 December: no day of it reads as a month, so every option's example is unambiguous. */
const SAMPLE = new Date(Date.UTC(2026, 11, 31));
/**
* The choices of the setting, each labelled by how it writes 31 December, and
* 'auto' by `autoLabel` with the language's own way in brackets.
+4 -53
View File
@@ -716,63 +716,14 @@ describe('getMergedItems: bookings in different time zones (#2929)', () => {
}
})
it('keeps a stop with no location, and a note, where their clock puts them', () => {
it('keeps the stops of the day where their clock puts them', () => {
const dayAssignments = [
{ id: 10, order_index: 0, place: { place_time: '09:00' } },
{ id: 11, order_index: 1, place: { place_time: '18:00' } },
]
const dayNotes = [{ id: 30, sort_order: 2, time: '18:30' }]
const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [toSeattle, toLA], dayId: DAY })
// The flights swap the slots they hold between them (17:00 and 20:20). Without
// coordinates there is no zone to read 18:00 or 18:30 in, so those keep their clock.
expect(result.map(i => `${i.type}:${i.data.id}`)).toEqual(['place:10', 'transport:1', 'place:11', 'note:30', 'transport:2'])
})
describe('a stop near an endpoint reads the zone of that endpoint', () => {
const AKL = { lat: -37.0, lng: 174.8, timezone: 'Pacific/Auckland' }
const LAX = { lat: 33.9, lng: -118.4, timezone: 'America/Los_Angeles' }
const SEA = { lat: 47.4, lng: -122.3, timezone: 'America/Los_Angeles' }
const ends = (from: typeof AKL, to: typeof AKL) => [
{ role: 'from', sequence: 0, name: 'A', code: null, local_time: null, local_date: null, ...from },
{ role: 'to', sequence: 1, name: 'B', code: null, local_time: null, local_date: null, ...to },
]
const aklLax = { ...toLA, endpoints: ends(AKL, LAX) }
const laxSea = { ...toSeattle, endpoints: ends(LAX, SEA) }
const stopAt = (id: number, time: string, lat: number, lng: number) =>
({ id, order_index: id, place: { place_time: time, lat, lng } })
const drawn = (dayAssignments: Parameters<typeof getMergedItems>[0]['dayAssignments'], dayTransports: Parameters<typeof getMergedItems>[0]['dayTransports']) =>
getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: DAY })
const labels = (items: MergedItem[]) => items.map(i => `${i.type}:${i.data.id}`)
it('draws a Seattle dinner after the flight to Seattle, and an Auckland breakfast first', () => {
const stops = [stopAt(10, '09:00', -36.85, 174.76), stopAt(12, '19:45', 47.61, -122.33)]
const items = drawn(stops, [laxSea, aklLax])
expect(labels(items)).toEqual(['place:10', 'transport:1', 'transport:2', 'place:12'])
// The move checks read the same keys, so the drawn order reads as in order.
const keys = items.map(chronoKeyOf(items, DAY, getDisplayTimeForDay))
expect(keys).toEqual([9 * 60, 17 * 60, 19 * 60 + 45, 20 * 60 + 20])
})
it('draws a dinner in Auckland at the same clock ahead of the flight out', () => {
expect(labels(drawn([stopAt(11, '19:45', -36.85, 174.76)], [laxSea, aklLax])))
.toEqual(['place:11', 'transport:1', 'transport:2'])
})
it('draws a lunch in Los Angeles after the Auckland flight that lands there', () => {
expect(labels(drawn([stopAt(13, '13:00', 34.05, -118.24)], [aklLax]))).toEqual(['transport:1', 'place:13'])
})
it('leaves a stop far from every endpoint on its clock', () => {
// Hawaii is no day out from Auckland, Los Angeles or Seattle, so its 19:45 reads no zone.
expect(labels(drawn([stopAt(14, '19:45', 21.3, -157.85)], [laxSea, aklLax])))
.toEqual(['transport:1', 'place:14', 'transport:2'])
})
it('changes nothing on a day whose stops and bookings share one zone', () => {
const hop = { ...laxSea, id: 4, reservation_time: '2026-11-20T08:00', endpoints: ends(LAX, SEA) }
const stops = [stopAt(15, '07:00', 34.05, -118.24), stopAt(16, '12:00', 47.61, -122.33), stopAt(17, '18:00', 47.61, -122.33)]
expect(labels(drawn(stops, [laxSea, hop]))).toEqual(['place:15', 'transport:4', 'place:16', 'transport:2', 'place:17'])
})
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports: [toSeattle, toLA], dayId: DAY })
// The flights swap the slots they hold between them (17:00 and 20:20); the stops stay put.
expect(result.map(i => `${i.type}:${i.data.id}`)).toEqual(['place:10', 'transport:1', 'place:11', 'transport:2'])
})
it('reads the zone from the endpoints when the metadata names none', () => {
+13 -61
View File
@@ -4,7 +4,6 @@ import { nightVisits, rideSeatAfter, sameDayRide, type CarrierBooking, type Seat
// that documents the multi-leg model. Sorting endpoints a second time here is how
// the two drift.
import { orderedEndpoints, parseReservationMetadata } from './flightLegs'
import { haversineKm, MAX_DAY_TRIP_KM } from './geo'
import { zonedInstant } from './zonedTime'
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other'])
@@ -362,10 +361,9 @@ function applyChronoOrder(
/**
* The minutes the day orders an item by: its own clock, or for a booking in a known
* zone and a stop near one of its endpoints the clock slot it takes among the day's
* zoned items (#2929, see zonedClockSlots). The checks that ask whether a move keeps the
* day in order read the same key, or a day whose flights and stops were put in the order
* they happen would read as out of order on every move.
* zone the clock slot it takes among the day's zoned bookings (#2929). The checks
* that ask whether a move keeps the day in order read the same key, or a day whose
* flights were put in the order they happen would read as out of order on every move.
*/
export function chronoKeyOf(
items: readonly MergedItem[],
@@ -401,22 +399,13 @@ function transportTimeZone(r: MergedItem['data'], dayId: number): string | null
}
/**
* The clock slots of the day's items whose time names an instant: a booking's dated time
* in a known zone, and a stop near one of those bookings' endpoints. Each local time reads
* in its own zone, so across zones the clock can run backwards: a flight leaving Auckland
* at 20:20 lands in Los Angeles at 12:25 the same date, and the 17:00 flight on from there
* sorted ahead of it (#2929). Such items keep the clock slots they hold between them and
* take them in the order they happen.
*
* A stop has to join them, or swapping the flights' slots moves every stop timed between
* the two clocks to the wrong side of the flight it belongs after: a 19:45 dinner in
* Seattle was drawn above the flight to Seattle. A stop carries no zone of its own, so it
* reads the zone of the nearest endpoint within a day's reach, which tells the 19:45
* dinner in Seattle from a 19:45 dinner in Auckland before the flight out. Notes, stops
* without coordinates or far from every endpoint, and bookings without a zone stay where
* their clock puts them. With fewer than two such items the map is empty and the day
* orders by the clock as before. Where every zone is the same, the order by instant is
* the order by clock and nothing moves.
* The clock slots of the day's bookings whose time names an instant: a dated time in a
* known zone. Each local time reads in its own zone, so across zones the clock can run
* backwards: a flight leaving Auckland at 20:20 lands in Los Angeles at 12:25 the same
* date, and the 17:00 flight on from there sorted ahead of it (#2929). Such bookings
* keep the clock slots they hold between them and take them in the order they happen,
* so stops, notes and bookings without a zone stay where the clock puts them. With fewer
* than two the map is empty and the day orders by the clock as before.
*/
function zonedClockSlots(
items: MergedItem[],
@@ -424,14 +413,12 @@ function zonedClockSlots(
getDisplayTime: DisplayTimeOf,
clockOf: (it: MergedItem) => number | null
): Map<MergedItem, number> {
const bookings = items.flatMap((it, index) => {
const zoned = items.flatMap((it, index) => {
if (it.type !== 'transport') return []
const clock = clockOf(it)
const time = getDisplayTime(it.data, dayId)
const instant = zonedInstant(time, transportTimeZone(it.data, dayId))
return clock === null || instant === null ? [] : [{ it, index, clock, instant, date: (time as string).slice(0, 10) }]
const instant = zonedInstant(getDisplayTime(it.data, dayId), transportTimeZone(it.data, dayId))
return clock === null || instant === null ? [] : [{ it, index, clock, instant }]
})
const zoned = [...bookings, ...stopsInZone(items, bookings, clockOf)]
const slots = new Map<MergedItem, number>()
if (zoned.length < 2) return slots
const clocks = zoned.map(z => z.clock).sort((a, b) => a - b)
@@ -485,41 +472,6 @@ export function yieldTimedBookings(
return drawnAsIs(result, dayId, getDisplayTime) ? result : null
}
/**
* The timed stops of the day that sit within a day's reach of an endpoint of the zoned
* bookings, with the instant their clock names in that endpoint's zone. The date is the
* one the bookings' times carry on this day; bookings that disagree on it leave every
* stop on its clock.
*/
function stopsInZone(
items: MergedItem[],
bookings: readonly { it: MergedItem; date: string }[],
clockOf: (it: MergedItem) => number | null
): { it: MergedItem; index: number; clock: number; instant: number }[] {
const dates = new Set(bookings.map(b => b.date))
if (dates.size !== 1) return []
const [date] = dates
const anchors = bookings.flatMap(b => orderedEndpoints(b.it.data))
.filter(e => Number.isFinite(e.lat) && Number.isFinite(e.lng) && !!e.timezone)
if (anchors.length === 0) return []
return items.flatMap((it, index) => {
if (it.type !== 'place') return []
const clock = clockOf(it)
const place = it.data?.place
if (clock === null || !Number.isFinite(place?.lat) || !Number.isFinite(place?.lng)) return []
let zone: string | null = null
let nearest = MAX_DAY_TRIP_KM
for (const e of anchors) {
const km = haversineKm({ lat: place.lat, lng: place.lng }, { lat: e.lat, lng: e.lng })
if (km <= nearest) { nearest = km; zone = e.timezone as string }
}
const hh = String(Math.floor(clock / 60)).padStart(2, '0')
const mm = String(clock % 60).padStart(2, '0')
const instant = zone ? zonedInstant(`${date}T${hh}:${mm}`, zone) : null
return instant === null ? [] : [{ it, index, clock, instant }]
})
}
/**
* A day order with a dragged booking or note at `index`, moved to the nearest slot the
* list draws it in. A row with a time is drawn by it, and the untimed rows behind it
+1 -30
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { frameLine, frameLng, tripLngFrame, viewLngFrame } from './lngFrame';
import { frameLine, frameLng, tripLngFrame } from './lngFrame';
const LA = -118.4;
const AUCKLAND = 174.8;
@@ -83,32 +83,3 @@ describe('frameLine', () => {
expect(drawn.map(([, lng]) => lng)).toEqual([30, 20]);
});
});
describe('viewLngFrame', () => {
it('keeps the plain world while the view looks at the real world', () => {
expect(viewLngFrame(null, 0)).toBeNull();
expect(viewLngFrame(null, 10)).toBeNull();
expect(viewLngFrame(null, -90)).toBeNull();
});
it('keeps the frame in use while the view stays near its middle, so a pan redraws nothing', () => {
const frame = { seam: 28.4 };
expect(viewLngFrame(frame, 208.4)).toBe(frame);
expect(viewLngFrame(frame, 130)).toBe(frame);
});
it('centres the frame on a view outside the trip frame, so places found there are drawn there', () => {
// A trip from Los Angeles to Auckland, seamed at 28.4, with the user looking at Europe.
const frame = viewLngFrame({ seam: 28.4 }, 10);
expect(frame).toEqual({ seam: -170 });
expect(frameLng(frame, 12)).toBeCloseTo(12, 6);
expect(frameLng(frame, 23.7)).toBeCloseTo(23.7, 6);
expect(frameLng(frame, -5)).toBeCloseTo(-5, 6);
});
it('centres the frame on a view of a world copy', () => {
const frame = viewLngFrame(null, 495);
expect(frame).toEqual({ seam: 315 });
expect(frameLng(frame, 139.7)).toBeCloseTo(499.7, 6);
});
});
-16
View File
@@ -106,19 +106,3 @@ export function frameLine<T extends readonly [number, number]>(
}
return out;
}
/**
* A frame for layers fetched for the visible area rather than drawn with the trip,
* such as the POIs the explore pill finds: those come back at their real longitudes
* and belong on the copy the user is looking at, even outside the trip's frame.
*
* The frame in use is kept while the view's centre stays within a quarter world of
* its middle, so a pan does not redraw every marker; past that it is centred on the
* view. Either way a view up to half a world wide lies inside it. `null` is the
* plain world, whose middle is 0, and is kept as null while it fits.
*/
export function viewLngFrame(current: LngFrame | null, centerLng: number): LngFrame | null {
const middle = current ? current.seam + 180 : 0;
if (Math.abs(centerLng - middle) <= 90) return current;
return { seam: centerLng - 180 };
}
@@ -10,7 +10,7 @@ import { buildPlanner, buildToast } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-POLLS-001 to FE-MOB-POLLS-030
// FE-MOB-POLLS-001 to FE-MOB-POLLS-024
const ME = 7
@@ -353,25 +353,6 @@ describe('MCollabPolls', () => {
expect(await screen.findByText('Which hotel?')).toBeInTheDocument()
})
it('FE-MOB-POLLS-030: a deadline whose time was cleared closes at the end of that day on the local clock', async () => {
const createPoll = vi.spyOn(collabApi, 'createPoll').mockResolvedValue({ poll: poll({ id: 6 }) })
setup([])
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
fireEvent.change(await screen.findByPlaceholderText('collab.polls.questionPlaceholder'), { target: { value: 'Q' } })
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:1'), { target: { value: 'A' } })
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: 'B' } })
setDeadline('2030-08-01')
// Emptying the time field leaves the picker with the bare day, which a plain
// new Date() would read as UTC midnight.
fireEvent.change(screen.getByPlaceholderText('00:00'), { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.create' }))
await waitFor(() => expect(createPoll).toHaveBeenCalledWith(1, expect.objectContaining({
deadline: new Date(2030, 7, 1, 23, 59).toISOString(),
})))
})
it('FE-MOB-POLLS-023: gates the submit button on a question plus two non-blank options', async () => {
setup([])
+135 -170
View File
@@ -122,7 +122,7 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.12",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.6.1",
"eslint": "^10.11.0",
"eslint-config-flat-gitignore": "^2.4.0",
@@ -141,9 +141,9 @@
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "8.3.5",
"vite": "8.3.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.12"
"vitest": "^4.1.11"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
@@ -11566,9 +11566,9 @@
"license": "ISC"
},
"node_modules/@vitest/coverage-istanbul": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.12.tgz",
"integrity": "sha512-f98TiCqF7IriBQEpm6usi56wyD4G1cRYabI15f3Jm7R355q9tlwqJ0j1oPf+tWZ7D0LPnCh3dU70duRt10erHg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.11.tgz",
"integrity": "sha512-pK9LJtOS534bmUwdwLpGD9qL52DeqVWSInrXFMNqrvWLbbAhuIPAlRup3UbsjFOMqCC/p3MmmX7uXV7ZTkcFfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11587,18 +11587,18 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.1.12"
"vitest": "4.1.11"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
"integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.12",
"@vitest/utils": "4.1.11",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -11612,8 +11612,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.12",
"vitest": "4.1.12"
"@vitest/browser": "4.1.11",
"vitest": "4.1.11"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -11622,16 +11622,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.12",
"@vitest/utils": "4.1.12",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -11640,13 +11640,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.12",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -11667,9 +11667,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11680,13 +11680,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.12",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@@ -11694,14 +11694,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.12",
"@vitest/utils": "4.1.12",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -11710,9 +11710,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -11720,13 +11720,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.12",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -19465,9 +19465,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"dev": true,
"funding": [
{
@@ -20443,9 +20443,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"dev": true,
"funding": [
{
@@ -20463,9 +20463,9 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.20",
"nanoid": "^3.3.18",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.2"
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -24613,16 +24613,16 @@
}
},
"node_modules/vite": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.30",
"rolldown": "~1.2.14",
"postcss": "^8.5.28",
"rolldown": "~1.2.6",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -24639,7 +24639,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
"@vitejs/devtools": "^0.7.1",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -24722,36 +24722,19 @@
}
},
"node_modules/vite/node_modules/@oxc-project/types": {
"version": "0.156.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz",
"integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==",
"version": "0.150.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
"integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/vite/node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
"integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/vite/node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz",
"integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz",
"integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==",
"cpu": [
"arm64"
],
@@ -24766,9 +24749,9 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz",
"integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz",
"integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==",
"cpu": [
"arm64"
],
@@ -24783,9 +24766,9 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz",
"integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz",
"integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==",
"cpu": [
"x64"
],
@@ -24800,9 +24783,9 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz",
"integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz",
"integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==",
"cpu": [
"x64"
],
@@ -24817,9 +24800,9 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz",
"integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz",
"integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==",
"cpu": [
"arm"
],
@@ -24834,16 +24817,13 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz",
"integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz",
"integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -24854,16 +24834,13 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz",
"integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz",
"integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -24874,16 +24851,13 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz",
"integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz",
"integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -24894,16 +24868,13 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz",
"integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz",
"integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -24914,16 +24885,13 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz",
"integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz",
"integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -24934,16 +24902,13 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz",
"integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz",
"integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -24954,9 +24919,9 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz",
"integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz",
"integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==",
"cpu": [
"arm64"
],
@@ -24971,9 +24936,9 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz",
"integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz",
"integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==",
"cpu": [
"arm64"
],
@@ -24988,9 +24953,9 @@
}
},
"node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz",
"integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz",
"integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==",
"cpu": [
"x64"
],
@@ -25018,13 +24983,13 @@
}
},
"node_modules/vite/node_modules/rolldown": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz",
"integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
"integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.156.0",
"@oxc-project/types": "=0.150.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -25034,37 +24999,37 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.16",
"@rolldown/binding-android-arm64": "1.2.16",
"@rolldown/binding-darwin-arm64": "1.2.16",
"@rolldown/binding-darwin-x64": "1.2.16",
"@rolldown/binding-freebsd-x64": "1.2.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.16",
"@rolldown/binding-linux-arm64-gnu": "1.2.16",
"@rolldown/binding-linux-arm64-musl": "1.2.16",
"@rolldown/binding-linux-ppc64-gnu": "1.2.16",
"@rolldown/binding-linux-s390x-gnu": "1.2.16",
"@rolldown/binding-linux-x64-gnu": "1.2.16",
"@rolldown/binding-linux-x64-musl": "1.2.16",
"@rolldown/binding-openharmony-arm64": "1.2.16",
"@rolldown/binding-win32-arm64-msvc": "1.2.16",
"@rolldown/binding-win32-x64-msvc": "1.2.16"
"@rolldown/binding-android-arm-eabi": "1.2.9",
"@rolldown/binding-android-arm64": "1.2.9",
"@rolldown/binding-darwin-arm64": "1.2.9",
"@rolldown/binding-darwin-x64": "1.2.9",
"@rolldown/binding-freebsd-x64": "1.2.9",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.9",
"@rolldown/binding-linux-arm64-gnu": "1.2.9",
"@rolldown/binding-linux-arm64-musl": "1.2.9",
"@rolldown/binding-linux-ppc64-gnu": "1.2.9",
"@rolldown/binding-linux-s390x-gnu": "1.2.9",
"@rolldown/binding-linux-x64-gnu": "1.2.9",
"@rolldown/binding-linux-x64-musl": "1.2.9",
"@rolldown/binding-openharmony-arm64": "1.2.9",
"@rolldown/binding-win32-arm64-msvc": "1.2.9",
"@rolldown/binding-win32-x64-msvc": "1.2.9"
}
},
"node_modules/vitest": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.12",
"@vitest/mocker": "4.1.12",
"@vitest/pretty-format": "4.1.12",
"@vitest/runner": "4.1.12",
"@vitest/snapshot": "4.1.12",
"@vitest/spy": "4.1.12",
"@vitest/utils": "4.1.12",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -25092,12 +25057,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.12",
"@vitest/browser-preview": "4.1.12",
"@vitest/browser-webdriverio": "^4.1.11",
"@vitest/coverage-istanbul": "4.1.12",
"@vitest/coverage-v8": "4.1.12",
"@vitest/ui": "4.1.12",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+1
View File
@@ -63,6 +63,7 @@
"client/src/utils/dayMerge.ts": 1,
"client/src/utils/flightLegs.ts": 3,
"client/src/utils/formatters.ts": 1,
"client/src/utils/geo.ts": 1,
"client/src/utils/placeSource.ts": 1,
"client/src/utils/placesSession.ts": 1,
"client/src/vacay/yearWindow.ts": 1,
+7 -19
View File
@@ -36,29 +36,17 @@ fi
# which on a large or network-backed one (NFS, CephFS, Longhorn) kept the
# container busy for minutes before Node printed its first line (#2918). So only
# the dir and what sits directly in it are looked at: a volume that is not
# node's yet (a fresh one, or one whose top level was copied in as root) shows
# there, and is handed over once. Files copied in as root further down, into a
# subdirectory node already owns, do not show and are left alone; the
# Troubleshooting page of the wiki has the one-off chown for that.
#
# Only the owning user has to be node, because that is what Node runs as and
# what gosu leaves it with. The group is left to whoever set it: a Kubernetes
# fsGroup other than 1000 gives every file its own gid, and taking that back
# here would make the kubelet's OnRootMismatch walk the volume again on the
# next start, and this script after it, every time. Only a root group (a fresh
# volume under plain docker) is handed to node as well.
#
# A volume that refuses chown altogether (NFS with root_squash) is said once
# per start instead of being walked file by file; such a pod belongs under
# runAsUser 1000, see the Helm page of the wiki. Best effort either way: a
# node's yet (a fresh one, or one copied in as root) shows there, and is handed
# over once. A volume that refuses chown altogether (NFS with root_squash) is
# said once per start instead of being walked file by file; such a pod belongs
# under runAsUser 1000, see the Helm page of the wiki. Best effort either way: a
# read-only host filesystem or a locked-down bind mount must keep booting.
for dir in /app/data /app/uploads; do
[ -d "$dir" ] || continue
if [ -n "$(find "$dir" -maxdepth 1 \( ! -user node -o -group 0 \) -print -quit 2>/dev/null)" ]; then
if chown node "$dir" 2>/dev/null; then
if [ -n "$(find "$dir" -maxdepth 1 \( ! -user node -o ! -group node \) -print -quit 2>/dev/null)" ]; then
if chown node:node "$dir" 2>/dev/null; then
echo "Correcting ownership of $dir for the node user..."
chown -R -h node "$dir" 2>/dev/null || true
find "$dir" -group 0 -exec chgrp -h node {} + 2>/dev/null || true
chown -R -h node:node "$dir" 2>/dev/null || true
else
echo "WARNING: $dir cannot be handed to the node user (chown refused); run the container as uid 1000 instead."
fi
+1 -1
View File
@@ -7,7 +7,7 @@
"src/db/repositories/Users.repository.ts": 1445,
"src/nest/atlas/atlas.service.ts": 1177,
"src/nest/auth/auth.service.ts": 1143,
"src/nest/budget/budget.service.ts": 1607,
"src/nest/budget/budget.service.ts": 1612,
"src/nest/collections/collections.service.ts": 1426,
"src/nest/doc-sync/doc-sync.service.ts": 1060,
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
@@ -97,7 +97,7 @@
"tests/unit/nest/memories/trek-photo-cache.job.test.ts": 1,
"tests/unit/nest/notification-preferences.service.test.ts": 4,
"tests/unit/nest/notification-transports.test.ts": 3,
"tests/unit/nest/notifications/storage-health-notifier.service.test.ts": 1,
"tests/unit/nest/notifications/storage-health-notifier.service.test.ts": 2,
"tests/unit/nest/notifications/vapid-keys.service.test.ts": 2,
"tests/unit/nest/notifications/web-push.service.test.ts": 1,
"tests/unit/nest/oauth.controller.test.ts": 1,
@@ -254,23 +254,6 @@ export class CollectionsRepository extends TrekRepository<Collections> {
await this.readDb().deleteFrom('collections').where('id', '=', id).execute();
}
/**
* `deleteCollection`: deletes a list like `deleteById` and hands back the uploads it
* named, its cover and its saved places' images, read before the cascade takes their
* rows, so they can go like a single place's do once nothing else names them (#2924).
*/
async deleteWithImages(id: number): Promise<Array<string | null>> {
const cover = (await this.coverImage(id)) ?? null;
const places = await this.placesReadDb()
.selectFrom('collection_places')
.select('image_url')
.where('collection_id', '=', id)
.where('image_url', 'is not', null)
.execute();
await this.deleteById(id);
return [cover, ...places.map((place) => place.image_url)];
}
/**
* CL36 (`reorderCollections`, TX `:712`) — `UPDATE collections SET
* sort_order=? WHERE id=?`, looped per visible id. The legacy statement
@@ -52,19 +52,6 @@ type JourneyListKyselyDB = Pick<
/** The `journeys`/`journey_contributors` tables {@link JourneysRepository.listRecipientUserIds} reads. */
type JourneyRecipientsKyselyDB = Pick<DB, 'journeys' | 'journey_contributors'>;
/** The tables {@link JourneysRepository.listGalleryPhotos} and {@link JourneysRepository.deleteUnlinkedPhotos} touch. */
type JourneyPhotoFilesKyselyDB = Pick<DB, 'journeys' | 'journey_photos' | 'trek_photos' | 'trip_photos'>;
/** A photo behind a journey's gallery, with the stored paths its row names. */
export interface JourneyGalleryPhotoFiles {
photo_id: number;
file_path: string | null;
thumbnail_path: string | null;
}
/** Ids per DELETE, well under SQLite's bound-parameter limit. */
const PHOTO_DELETE_BATCH = 500;
/**
* `journeys` — the root of the journey cluster (Plan 3g Task 1, R9's Part A).
* Every method here is a Part-A-owned statement (JG1-JG29 group) or a shared
@@ -274,49 +261,4 @@ export class JourneysRepository extends TrekRepository<Journeys> {
async deleteOwnedByUser(userId: number): Promise<void> {
await this.nativeDelete({ user: userId });
}
/**
* The photos in the gallery of one journey, or of every journey a user owns,
* with the paths their `trek_photos` rows store. Read before the journey is
* deleted: the cascade takes the gallery rows, and with them the only way
* back to the photos.
*/
async listGalleryPhotos(scope: { journeyId: number } | { ownerId: number }): Promise<JourneyGalleryPhotoFiles[]> {
const [column, value] =
'journeyId' in scope ? (['j.id', scope.journeyId] as const) : (['j.user_id', scope.ownerId] as const);
return await this.kysely<JourneyPhotoFilesKyselyDB>()
.selectFrom('journey_photos as jp')
.innerJoin('journeys as j', 'j.id', 'jp.journey_id')
.innerJoin('trek_photos as tp', 'tp.id', 'jp.photo_id')
.select(['jp.photo_id', 'tp.file_path', 'tp.thumbnail_path'])
.distinct()
.where(column, '=', value)
.execute();
}
/**
* Deletes the `trek_photos` rows among `photoIds` that no trip and no journey
* links any more, after their journeys went: the same rule as
* `TrekPhotoRegistrationService.deleteIfOrphan` with `local`, for a whole
* gallery at once. A row left behind would keep naming its file, and the
* storage reclaim counts every row with a path as live (#2924). A photo still
* in another journey's gallery, or in a trip's, keeps its row. Answers how
* many rows went.
*/
async deleteUnlinkedPhotos(photoIds: readonly number[]): Promise<number> {
const pages = Array.from({ length: Math.ceil(photoIds.length / PHOTO_DELETE_BATCH) }, (_unused, i) =>
photoIds.slice(i * PHOTO_DELETE_BATCH, (i + 1) * PHOTO_DELETE_BATCH),
);
// One page after the other: they share the caller's transaction.
return await pages.reduce<Promise<number>>(async (previous, page) => {
const deleted = await previous;
const result = await this.kysely<JourneyPhotoFilesKyselyDB>()
.deleteFrom('trek_photos')
.where('id', 'in', page)
.where('id', 'not in', (eb) => eb.selectFrom('trip_photos').select('photo_id'))
.where('id', 'not in', (eb) => eb.selectFrom('journey_photos').select('photo_id'))
.executeTakeFirst();
return deleted + Number(result.numDeletedRows);
}, Promise.resolve(0));
}
}
+1 -6
View File
@@ -120,13 +120,8 @@ export class AddonsService {
return this.photoProviders.listEnabledIds();
}
/**
* wanderer may be used: Tours is on and the admin allowed it under Tours. Never on a managed
* install, where it would send searches to a server the user picks; the admin switch is locked
* there, so a value stored before the install became managed must not keep it on.
*/
/** wanderer may be used: Tours is on and the admin allowed it under Tours. */
async isWandererAvailable(): Promise<boolean> {
if (readEnv().managed.enabled) return false;
return (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursWanderer()).enabled;
}
+4 -14
View File
@@ -264,14 +264,8 @@ interface Turn {
history: AiChatMessage[];
usage: { inputTokens: number; cachedInputTokens: number; outputTokens: number };
knownPlaceIds: Set<string>;
/** Whether the turn used its one push (an empty, cut-off or announcing answer). */
/** Whether the turn used its one push (an empty, cut-off, announcing or wordless answer). */
nudged: boolean;
/**
* Whether the turn already asked for the answer after a step that only
* offered follow-ups. Apart from the push, so a turn that was pushed once
* still gets its answer written (#2789); each is asked at most once.
*/
askedForAnswer: boolean;
/** The chips of follow-ups offered before any answer, shown under the answer once it is written. */
followUps: AssistantFollowUps | null;
}
@@ -534,7 +528,7 @@ function answeredAsk(call: AiToolCall): AssistantMessagePart {
*/
async function askForTheAnswer(turn: Turn, rowId: number, step: StepRecord & { asks: AiToolCall[] }): Promise<null> {
const { asks, providerState } = step;
turn.askedForAnswer = true;
turn.nudged = true;
turn.followUps = followUpsOf(asks[asks.length - 1].args);
const parts = [...step.parts, ...asks.map((call) => ({ ...answeredAsk(call), result: WRITE_THE_ANSWER }))];
await turn.deps.store.saveStep(rowId, { parts, providerState }, 'complete');
@@ -566,15 +560,12 @@ async function runStep(turn: Turn, step: number): Promise<AgentOutcome | null> {
...searched.tail,
];
const empty = received.length === 0 && !text.trim();
if (empty && asks.length > 0 && !turn.askedForAnswer)
return askForTheAnswer(turn, rowId, { parts, providerState, asks });
if (empty && asks.length > 0 && !turn.nudged) return askForTheAnswer(turn, rowId, { parts, providerState, asks });
// An answer that offered follow-ups is finished, however it ends.
const settled = asks.length > 0 && !empty;
const announced = !settled && received.length === 0 && step === 0 && ANNOUNCED.test(text.trimEnd());
const push = empty || (!settled && received.length === 0 && cutOff) ? GO_ON : announced ? MAKE_THE_CALLS : null;
// Follow-ups with no answer a second time end the turn: the model was already asked for the answer.
const wordlessAgain = empty && asks.length > 0;
if (push && !turn.nudged && !wordlessAgain) {
if (push && !turn.nudged) {
turn.nudged = true;
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
w.finishStep();
@@ -672,7 +663,6 @@ export async function runAgentLoop(
usage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
knownPlaceIds,
nudged: false,
askedForAnswer: false,
followUps: null,
};
return stepsFrom(turn, 0);
+13 -38
View File
@@ -26,19 +26,6 @@ import {
type AssistantToolApproval,
} from '@trek/shared';
/** A recorded call's arguments as an object: given as one, or as JSON text; anything else is none. */
function recordOf(value: unknown): Record<string, unknown> {
let parsed = value;
if (typeof value === 'string') {
try {
parsed = JSON.parse(value);
} catch {
return {};
}
}
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
}
/**
* A thread's stored messages, read two ways: as the conversation the
* provider is sent, and as the messages the client shows.
@@ -61,23 +48,17 @@ const NOT_RUN = 'This call did not run: the answer was stopped before it.';
* The calls a provider's own record of a step holds, which it replays as they
* came and wants answered: Anthropic's tool uses, the Responses API's function
* calls. A step stopped while its calls ran (the user left the page) keeps the
* whole record but answers only the calls that ran. The arguments come along,
* so an adapter that rebuilds the step from its calls (another provider than
* the one that recorded it) still finds every call an answer names.
* whole record but answers only the calls that ran.
*/
function recordedCalls(state: AiProviderState | null): AiToolCall[] {
const calls =
function recordedCalls(state: AiProviderState | null): Array<{ id: string; name: string }> {
const entries =
state?.adapter === 'anthropic-messages'
? state.blocks
.filter((block) => block.type === 'tool_use')
.map((block) => ({ id: block.id, name: block.name, args: recordOf(block.input) }))
? state.blocks.filter((block) => block.type === 'tool_use').map((block) => [block.id, block.name])
: state?.adapter === 'openai-responses'
? state.items
.filter((item) => item.type === 'function_call')
.map((item) => ({ id: item.call_id, name: item.name, args: recordOf(item.arguments) }))
? state.items.filter((item) => item.type === 'function_call').map((item) => [item.call_id, item.name])
: [];
return calls.flatMap(({ id, name, args }) =>
typeof id === 'string' ? [{ id, name: typeof name === 'string' ? name : '', args }] : [],
return entries.flatMap(([id, name]) =>
typeof id === 'string' ? [{ id, name: typeof name === 'string' ? name : '' }] : [],
);
}
@@ -333,16 +314,7 @@ export function toChatHistory(
const text = textOf(parts);
if (!text && toolCalls.length === 0) return;
const providerState = decodeJson(ASSISTANT_PROVIDER_STATE, row.provider_state, `message ${row.id}`);
// The recorded calls that never ran go into the step's calls as well: an adapter that does not
// replay this record rebuilds the step from them, and would otherwise find answers to calls it never made.
const answered = new Set(toolCalls.map((call) => call.id));
const unrun: AiToolCall[] = [];
for (const recorded of recordedCalls(providerState)) {
if (answered.has(recorded.id)) continue;
answered.add(recorded.id);
unrun.push(recorded);
}
history.push({ role: 'assistant', text, toolCalls: [...toolCalls, ...unrun], providerState });
history.push({ role: 'assistant', text, toolCalls, providerState });
calls.forEach((part, index) => {
history.push({
role: 'tool',
@@ -352,8 +324,11 @@ export function toChatHistory(
...(part.isError ? { isError: true } : {}),
});
});
for (const call of unrun) {
history.push({ role: 'tool', toolCallId: call.id, name: call.name, output: NOT_RUN, isError: true });
const answered = new Set(toolCalls.map((call) => call.id));
for (const recorded of recordedCalls(providerState)) {
if (answered.has(recorded.id)) continue;
answered.add(recorded.id);
history.push({ role: 'tool', toolCallId: recorded.id, name: recorded.name, output: NOT_RUN, isError: true });
}
});
return history;
@@ -59,7 +59,6 @@ import {
TRANSIT_CREATE_TOOL,
TRANSIT_SEARCH_TOOL,
} from './cards/roadtrip/transit-restore';
import { argsFitSchema } from './tool-args-fit';
import { MikroORM } from '@mikro-orm/core';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
@@ -267,12 +266,9 @@ export class AssistantToolSession {
/** The groups this turn loaded; the core is always there. */
private readonly loaded = new Set<string>();
/** Reads the model called before their group was loaded in this turn, by name, with that group; told once. */
/** Tools the model called before their group was loaded in this turn, by name, with that group. */
private readonly calledBlind = new Map<string, string>();
/** Groups a blind call loaded whose schemas no step has been offered yet. */
private readonly unoffered = new Set<string>();
/** Whether the session offers its tools by group: a trip's turn and a home turn do. */
private get grouped(): boolean {
return this.mode === 'turn' || this.mode === 'home';
@@ -284,8 +280,6 @@ export class AssistantToolSession {
* journey's session offers all it holds and no more.
*/
specs(): AiToolSpec[] {
// The step these specs go to sees every loaded group's schema.
this.unoffered.clear();
const listed = [...this.tools.values()]
.filter((tool) => tool.offered)
.filter((tool) => !this.grouped || tool.hint.group === null || this.loaded.has(tool.hint.group))
@@ -344,27 +338,9 @@ export class AssistantToolSession {
// The model called a tool it was not offered in this turn (it knows the
// name from an earlier turn), so it wrote the arguments without the
// schema (#2967). Its group is loaded here, so the next step offers the
// schema.
// schema, and a refusal of these arguments says so (see call()).
this.loaded.add(unloaded);
this.unoffered.add(unloaded);
}
const group = this.tools.get(name)?.hint.group;
if (group && this.unoffered.has(group)) {
// A change is never run in the turn but held as a card, and the turn
// ends there: arguments written without the schema would only fail once
// the user applies it, with the model long gone. Such a call is sent back
// for the next step; one whose arguments already fit is held as always.
const tool = this.tools.get(name);
if (tool?.kind === 'write' && !argsFitSchema(args, modelSchema(name, tool.inputSchema, this.pinArg))) {
return {
error: `${name} belongs to the "${group}" tools, which were not loaded in this turn, so it did not run. They are loaded now: send the call again with the arguments exactly as its schema describes them.`,
};
}
// A read runs at once; a refusal of its arguments says why (see call()).
this.calledBlind.set(name, group);
} else {
// A blind read refused before it ran (another trip, an unknown place) must not hint a later call.
this.calledBlind.delete(name);
this.calledBlind.set(name, unloaded);
}
const given = pinNamesOf(this.pinArg).filter((key) => args[key] !== undefined);
if (given.some((key) => Number(args[key]) !== this.pinnedId)) {
@@ -385,10 +361,7 @@ export class AssistantToolSession {
const final = name === TRANSIT_CREATE_TOOL ? await this.withFoundItinerary(args) : args;
const run = await this.invoke(name, final);
this.audit(name);
// Only the blind call itself is told about the schema: a later failure of the
// same tool, written with the schema in hand, has another reason.
const blind = this.calledBlind.get(name);
this.calledBlind.delete(name);
if (!run.ok && blind) {
const hint = `${name} was called before the "${blind}" tools were loaded in this turn, so its arguments were written without its schema. They are loaded now and the schema is in your tools: send the call again with the arguments exactly as it describes them.`;
return {
@@ -1,38 +0,0 @@
/**
* Whether a tool call's arguments are of the shape its JSON schema asks for, as far as
* a blind call can get it wrong: a required argument missing, or a value of the wrong
* kind, such as a number or a list written as text (#2967). Not a full validator: a
* property without a plain `type` (a union, a reference) is taken as it comes, and
* the tool still validates everything when it runs.
*/
export function argsFitSchema(args: Record<string, unknown>, schema: Record<string, unknown>): boolean {
const properties = (schema.properties as Record<string, { type?: unknown }> | undefined) ?? {};
const required = Array.isArray(schema.required) ? (schema.required as string[]) : [];
if (required.some((key) => args[key] === undefined)) return false;
return Object.entries(args).every(([key, value]) => {
const declared = properties[key]?.type;
if (value === undefined || declared === undefined) return true;
const types = Array.isArray(declared) ? declared : [declared];
return types.some((type) => isOfType(value, type));
});
}
function isOfType(value: unknown, type: unknown): boolean {
switch (type) {
case 'null':
return value === null;
case 'integer':
return Number.isInteger(value);
case 'number':
return typeof value === 'number' && Number.isFinite(value);
case 'array':
return Array.isArray(value);
case 'object':
return typeof value === 'object' && value !== null && !Array.isArray(value);
case 'string':
case 'boolean':
return typeof value === type;
default:
return true;
}
}
+4 -13
View File
@@ -191,23 +191,14 @@ export class UserCleanupService {
*/
async deleteUserCompletely(userId: number): Promise<void> {
const avatar = await this.usersRepo.getAvatar(userId);
const photos = await this.uow.transactional(async () => {
// The galleries of the journeys the user owns go with the cascade below, so
// their photos are read first. A photo row none of the remaining trips and
// journeys links goes too, as when one journey is deleted: left behind, it
// would keep naming its file for the storage reclaim forever (#2924).
const gallery = await this.journeysRepo.listGalleryPhotos({ ownerId: userId });
await this.uow.transactional(async () => {
await this.cleanupUserReferences(userId);
await this.journeysRepo.deleteUnlinkedPhotos(gallery.map((photo) => photo.photo_id));
await this.erasePluginUserData(userId);
await this.usersRepo.deleteById(userId);
return gallery;
});
// After the commit, the uploaded avatar and those photos' files go unless
// another row names them. What the user's trips named is the nightly
// reclaim pass's: those rows are other domains' and went with the cascade.
// After the commit, the uploaded avatar goes unless another row names it.
// What the user's trips and journeys named is the nightly reclaim pass's:
// those rows are other domains' and went with the cascade.
await this.release.releaseValues([avatar], { bare: 'avatars' });
const photoFiles = photos.flatMap((photo) => [photo.file_path, photo.thumbnail_path]);
await this.release.releaseValues(photoFiles, { bare: 'journey' });
}
}
+1 -77
View File
@@ -236,81 +236,5 @@ export function settlementFx(opts: {
// inverse of the display factor, so such a transfer reads back as entered.
const settleConverterFor = (sCurrency?: string | null, sRate?: number | null) =>
sCurrency ? tripConverter(sCurrency, sRate, tripCurrency, rates) : (amount: number) => amount / displayFactor;
return { currency, displayFactor, displayUnit, toDisplay, converterFor, settleConverterFor, isDirect };
}
/** One member's settlement ledger: trip-currency cents and display-currency hundredths. */
interface LedgerHalves {
cents: number;
direct: number;
}
/** What `netOppositeHalves` hands back when nothing moves. */
const NOTHING_BACK = { back: 0, backExact: 0 } as const;
/**
* When the two halves point opposite ways between a pair (the payer is owed in
* display-currency expenses but owes more in the trip currency: one paid a euro train,
* the other a yen hotel), a transfer in the display currency settles the net of both.
* The display-currency credit then moves into the trip half at this transfer's frozen
* rate, against the trip debt it was netted with. Left where it was, it stayed fixed
* while the yen it offset floated with the live rate, and a settled trip reopened as
* soon as the rate moved (#1445). Only as much moves as both halves hold, in whole
* steps of the display currency, so neither side ends up owing the other way.
*
* Only for a transfer in the display currency that moves money (`applies`). Moves it
* on `from` and `to` and returns the hundredths moved (`back`) and their exact
* trip-currency value in cents (`backExact`).
*/
function netOppositeHalves(
applies: boolean,
from: LedgerHalves,
to: LedgerHalves,
convert: (amount: number) => number,
displayUnit: number,
): { back: number; backExact: number } {
if (!applies) return NOTHING_BACK;
const directRoom = Math.min(from.direct, -to.direct);
const tripRoom = Math.min(-from.cents, to.cents);
if (directRoom <= 0 || tripRoom <= 0) return NOTHING_BACK;
const fits = Math.round(tripRoom / convert(1) / displayUnit) * displayUnit;
const back = Math.max(0, Math.min(directRoom, fits));
const backExact = convert(back / 100) * 100;
const backTrip = Math.round(backExact);
from.direct -= back;
to.direct += back;
from.cents += backTrip;
to.cents -= backTrip;
return { back, backExact };
}
/**
* Books one settle-up transfer on the payer's (`from`) and the receiver's (`to`) ledger.
* A transfer in the display currency first pays what the payer owes the receiver in
* display-currency expenses, as typed (#2965), in whole steps of the display currency
* like the direct half it pays (#2604). Only the rest goes through the trip currency at
* its frozen rate, which keeps a debt in the trip currency settled when rates move
* (#1445), rounded to a trip cent per transfer so no sliver of a cent builds up over a
* trip. Halves pointing opposite ways then settle as their net (`netOppositeHalves`).
*/
export function applyTransfer(
from: LedgerHalves,
to: LedgerHalves,
amount: number,
inDisplay: boolean,
convert: (amount: number) => number,
displayUnit: number,
): { paidDirect: number; exact: number; back: number; backExact: number } {
const total = Math.round(amount * 100);
const room = inDisplay ? Math.max(0, Math.min(-from.direct, to.direct)) : 0;
const paidDirect = total > 0 ? Math.floor(Math.min(total, room) / displayUnit) * displayUnit : 0;
from.direct += paidDirect;
to.direct -= paidDirect;
const rest = total === 0 ? 0 : (total - paidDirect) / total;
const exact = convert(amount) * 100 * rest;
const inTrip = Math.round(exact);
from.cents += inTrip;
to.cents -= inTrip;
const { back, backExact } = netOppositeHalves(inDisplay && total > 0, from, to, convert, displayUnit);
return { paidDirect, exact, back, backExact };
return { currency, displayFactor, toDisplay, converterFor, settleConverterFor, isDirect };
}
+28 -20
View File
@@ -31,7 +31,7 @@ import { BUDGET_ITEM_TICKET, RESERVATION_METADATA } from '../../db/json-columns'
import { decodeJson, decodeJsonResult, encodeJson } from '../../utils/json-column';
import { DomainError } from '../common/domain-error';
import { GUEST_HAS_PAYMENTS, shareAfterLeaving } from './budget-departure.helpers';
import { applyTransfer, customSplitCents, isFrozenRate, NOT_FROZEN_RATE, settlementFx, summaryShareCents, sumMoney, tripConverter } from './budget-money.helpers';
import { customSplitCents, isFrozenRate, NOT_FROZEN_RATE, settlementFx, summaryShareCents, sumMoney, tripConverter } from './budget-money.helpers';
type Trip = TripAccess;
@@ -91,8 +91,7 @@ export function splitLegacyTicketNote(
* queries instead of a full listSettlements() scan, settlement usernames use
* COALESCE(display_name, username) like every item query, and updateMembers no
* longer double-applies avatarUrl.
*/
@Injectable()
*/@Injectable()
export class BudgetService {
constructor(
private readonly permissions: PermissionsService,
@@ -1083,7 +1082,7 @@ export class BudgetService {
tripId: string | number,
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string; baseRate?: number } = {},
) {
const { currency, displayFactor, displayUnit, toDisplay, converterFor, settleConverterFor, isDirect } = settlementFx(opts);
const { currency, displayFactor, toDisplay, converterFor, settleConverterFor, isDirect } = settlementFx(opts);
const unconvertedItems: number[] = [];
const unconvertedSettlements: number[] = [];
const unconvertedCurrencies = new Set<string>();
@@ -1137,9 +1136,6 @@ export class BudgetService {
// convert is reported whole and moves nothing, credits and shares alike.
const inDisplay = isDirect(item.currency);
const convert = inDisplay ? (amount: number) => amount : converterFor(item.currency, item.exchange_rate);
// The direct half skips toDisplay, so it is kept in whole steps of the display
// currency here instead: whole won or yen, as the rest of the answer (#2604).
const unit = inDisplay ? displayUnit : 1;
if (!convert) {
unconvertedItems.push(item.id);
unconvertedCurrencies.add((item.currency || '').toUpperCase());
@@ -1173,7 +1169,7 @@ export class BudgetService {
let creditCents = 0;
for (const p of payers) {
const exact = convert(p.amount) * 100;
const paid = Math.round(exact / unit) * unit;
const paid = Math.round(exact);
const payer = ensure(p.user_id, p);
if (inDisplay) {
payer.direct += paid;
@@ -1198,13 +1194,12 @@ export class BudgetService {
// here since #2225. An item whose payers net to exactly zero still does, and
// divides zero, which is what it is worth.
const hasCustomSplit = members.some(m => m.amount !== null && m.amount !== undefined);
// Split in those steps too (creditCents is a whole number of them), then back to hundredths.
const shares = hasCustomSplit
? customSplitCents(members, amount => convert(amount) / unit, creditCents / unit, sumMinor(payers.map(p => p.amount)))
: splitEqualShares(creditCents / unit, members, item.id);
? customSplitCents(members, convert, creditCents, sumMinor(payers.map(p => p.amount)))
: splitEqualShares(creditCents, members, item.id);
for (const m of members) {
const member = ensure(m.user_id, m);
if (inDisplay) member.direct -= (shares[m.user_id] || 0) * unit;
if (inDisplay) member.direct -= shares[m.user_id] || 0;
else member.cents -= shares[m.user_id] || 0;
}
}
@@ -1230,20 +1225,33 @@ export class BudgetService {
}
const from = ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url);
const to = ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url);
// Both halves of the ledger take the transfer, the display currency's first (applyTransfer).
const { paidDirect, exact, back, backExact } = applyTransfer(from, to, s.amount, isDirect(s.currency), convert, displayUnit);
const movedDirect = paidDirect - back;
reimbursedDirect[s.to_user_id] = (reimbursedDirect[s.to_user_id] || 0) + movedDirect;
reimbursedDirect[s.from_user_id] = (reimbursedDirect[s.from_user_id] || 0) - movedDirect;
// A transfer in the display currency first pays what the payer owes the
// receiver in display-currency expenses, as typed (#2965); only the rest
// goes through the trip currency at its frozen rate, which keeps a debt
// in the trip currency settled when rates move (#1445).
const total = Math.round(s.amount * 100);
const room = isDirect(s.currency) ? Math.max(0, Math.min(-from.direct, to.direct)) : 0;
const paidDirect = total > 0 ? Math.min(total, room) : 0;
from.direct += paidDirect;
to.direct -= paidDirect;
reimbursedDirect[s.to_user_id] = (reimbursedDirect[s.to_user_id] || 0) + paidDirect;
reimbursedDirect[s.from_user_id] = (reimbursedDirect[s.from_user_id] || 0) - paidDirect;
// Rounded to a trip cent per transfer, so recording one in a display currency
// can't leave a sliver of a cent behind to accumulate over a trip's lifetime.
const rest = total === 0 ? 0 : (total - paidDirect) / total;
const exact = convert(s.amount) * 100 * rest;
const inTrip = Math.round(exact);
from.cents += inTrip;
to.cents -= inTrip;
// Net of the transfers in both directions: sending one back is a reimbursement
// received in reverse, and netting them is what keeps the final budget's
// subtraction equal to the balance it is taken from.
const shown = finalUnit(exact) + finalUnit(backExact);
const shown = finalUnit(exact);
reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + shown;
reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - shown;
const moved = { settlement_id: s.id, from_user_id: s.from_user_id, to_user_id: s.to_user_id };
(movedRows[s.to_user_id] ??= []).push({ ...moved, cents: shown, direct: movedDirect });
(movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -shown, direct: -movedDirect });
(movedRows[s.to_user_id] ??= []).push({ ...moved, cents: shown, direct: paidDirect });
(movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -shown, direct: -paidDirect });
}
// Into the display currency as one set, then simplify — balances and flows are
@@ -615,7 +615,7 @@ export class CollectionsService {
const accepted = await this.members.acceptedUserIds(id);
const pending = await this.members.pendingUserIds(id);
await this.release.releaseValues(await this.collectionsRepo.deleteWithImages(id)); // cascades members, places, tags
await this.collectionsRepo.deleteById(id); // CASCADE drops members + places + tags
[...new Set([userId, ...accepted, ...pending])] // the owner's tabs too, all but the one that asked
.forEach(uid => this.realtime.broadcastToUser(uid, { type: 'collections:deleted', collectionId: id }, socketId));
@@ -8,7 +8,6 @@ import { RealtimeService } from '../realtime/realtime.service';
import type { JourneyStats, JourneyTrack, TrekWsUserEventName } from '@trek/shared';
import { todayUtc } from '@trek/shared';
import { TrekPhotoRegistrationService } from '../photos/trek-photo-registration.service';
import { StorageReleaseService } from '../storage/storage-release.service';
import { getCountryFromCoords } from '../atlas/atlas-geo';
import { computeJourneyStats, countryNamesFor, type StatsInputPoint } from './journey-stats';
import { newEntrySortOrder, placeMovedEntry } from './journey-entry-order';
@@ -85,12 +84,23 @@ export class JourneyDomainService {
// (Task 0's confirmation: `canAccessTrip` IS `findAccessible`, not
// merely equivalent to it).
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
// The journey's photos and the entries they hang on (JG15/JG19/JG87-116).
// Plan 3g Task 2 (Part B) — the full photos surface (JG19/JG87-116),
// finishing the two sites Task 1 left raw (JG15/JG19) because their
// owning repositories did not exist yet. A genuine new constructor
// parameter pair beyond what Task 1's own constructor-ripple fix
// anticipated — flagged in task-2-report.md per the brief's own
// contingency instruction, with the companion edits to
// `journey-domain.module.ts`'s `forFeature` array and the two shared
// test-wiring helpers (`plugin-host.ts`/`mcp-test-controllers.ts`,
// both confirmed clean via `git status` before editing, per the
// brief's "constructor-ripple rule" allowance) made in this same
// change.
@InjectRepository(JourneyPhotos) private readonly photosRepo: JourneyPhotosRepository,
@InjectRepository(JourneyEntryPhotos) private readonly entryPhotosRepo: JourneyEntryPhotosRepository,
// JG44 — `onPlaceUpdated` reads the place it follows (`PlacesRepository.findRaw`).
// JG44 — `onPlaceUpdated`'s bare `SELECT * FROM places WHERE id = ?`,
// finished via `PlacesRepository.findRaw` (this task's ONE append to
// `Places.repository.ts`, per the task brief — nobody else holds it).
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
private readonly release: StorageReleaseService, // frees the files of a deleted journey's own photos
) {}
private ts(): number {
@@ -358,17 +368,7 @@ export class JourneyDomainService {
if (!(await this.isOwner(journeyId, userId))) return false;
// Read first: the delete takes the contributor rows with it.
const audience = await this.journeysRepo.listRecipientUserIds(journeyId);
// The gallery goes with the cascade, so it is read first; a photo row only this journey linked goes too, or
// it would keep naming its file and the reclaim never free it (#2924). One a trip still links stays.
const photos = await this.uow.transactional(async () => {
const gallery = await this.journeysRepo.listGalleryPhotos({ journeyId });
await this.journeysRepo.deleteById(journeyId);
await this.journeysRepo.deleteUnlinkedPhotos(gallery.map((photo) => photo.photo_id));
return gallery;
});
// After the commit; the release asks every source again, so a file another row names stays.
const photoFiles = photos.flatMap((photo) => [photo.file_path, photo.thumbnail_path]);
await this.release.releaseValues(photoFiles, { bare: 'journey' });
await this.journeysRepo.deleteById(journeyId);
await this.broadcastJourneyEvent(journeyId, 'journey:deleted', {}, sid, audience);
return true;
}
@@ -1,6 +1,5 @@
import { withRequestContext } from '../database/request-context';
import { StorageEventsService } from '../storage/storage-events.service';
import { offeredEventTypes } from './notification-preferences.service';
import { NotificationsService } from './notifications.service';
import { ReplicaFailureDebouncer } from './replica-failure-debouncer';
import { MikroORM } from '@mikro-orm/core';
@@ -45,19 +44,8 @@ export class StorageHealthNotifierService implements OnApplicationBootstrap {
onApplicationBootstrap(): void {
this.events.onReplicaFailure((failure) => {
// On a managed install the replicas are the operator's, and the
// preference matrix has no row to mute this event, so the admins there
// would get alerts they can neither act on nor switch off. The warning
// keeps the failure in the operator's logs, behind the same debounce so
// an outage is one line an hour per backend rather than one per write.
const suppressed = this.debouncer.admit(failure.backend);
if (suppressed === null) return;
if (!offeredEventTypes().includes('replica_failure')) {
this.logger.warn(
`replica ${failure.op} failed on ${failure.backend}: ${failure.error} (${suppressed} suppressed)`,
);
return;
}
const sendNotification = () =>
this.notifications
.send({

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