mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-10 06:11:02 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c42bbc4239 | |||
| a9ffcbc134 | |||
| 91defd40fd | |||
| bde3bb4a57 | |||
| cc01f7df32 | |||
| 80de282e1a | |||
| 870b03c6c8 | |||
| 037e739807 | |||
| d02e58387d | |||
| a6796765a2 | |||
| 5a62aa4541 | |||
| 9790ad28e1 | |||
| 2ce44a12c5 | |||
| cb6149f8ba | |||
| 8963180006 | |||
| 5c37dd43c5 | |||
| 6f3c051c7c | |||
| 99c1a2d6c7 | |||
| a3b04887a1 | |||
| 8b6a65023f | |||
| 7572c610a6 | |||
| f567e15a99 | |||
| b1479d939a | |||
| 71913eb6e2 | |||
| 601ed66a38 | |||
| 9a69b092e8 | |||
| be211c22ed | |||
| 293b25466e | |||
| 1ba58e8b89 | |||
| ae0d4e4520 | |||
| c5765bbf15 | |||
| 9832d12751 | |||
| 14825df993 | |||
| e12be0a185 | |||
| 9f1f33213b | |||
| b2c029f02a | |||
| 915a1f64e6 |
+1
-1
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 ----------------- */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([])
|
||||
|
||||
|
||||
Generated
+135
-170
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user