mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbcfbd875e | |||
| d2002cc913 | |||
| 4bc764eb6a | |||
| 1f89a13f92 | |||
| 93a23f0343 | |||
| ce69ae99a1 | |||
| c7d5fc12c6 | |||
| b9c53d692b | |||
| 13376d560f | |||
| 3a8191fc07 | |||
| c0f7f81e23 | |||
| 7743e4da16 | |||
| 887f5d8c84 | |||
| 2ec1199490 | |||
| 9dd58fe32f | |||
| 402a02a302 | |||
| 7b5a05afee | |||
| c3357ebea4 | |||
| 91f6430762 | |||
| 6ee540d5e4 | |||
| 99cdcfcd09 | |||
| fd11944ed6 | |||
| 82c6a3cd5f | |||
| fbafa270c4 | |||
| 1c54d7d6c1 | |||
| d2c7d83256 | |||
| 91630dd811 | |||
| 14c767a675 | |||
| da324121bd | |||
| b41f6e3542 | |||
| 9f5d01ee09 | |||
| 67ca58f2a5 | |||
| 9eab19ea6c | |||
| 50ea9a74c9 | |||
| 82295a1d3f | |||
| 3c78452c14 |
+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 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`.
|
||||
- `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`.
|
||||
- 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`.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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
@@ -123,7 +123,7 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"autoprefixer": "^10.6.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-config-flat-gitignore": "^2.4.0",
|
||||
@@ -142,8 +142,8 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "8.3.0",
|
||||
"vite": "8.3.5",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 380,
|
||||
"@typescript-eslint/no-non-null-assertion": 377,
|
||||
"@typescript-eslint/no-misused-promises": 847,
|
||||
"no-console": 88,
|
||||
"complexity": 218,
|
||||
@@ -8,7 +8,7 @@
|
||||
"trek/no-swallowed-catch": 128,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/refs": 553,
|
||||
"react-hooks/set-state-in-effect": 193,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1337,
|
||||
"src/components/Map/MapView.tsx": 1255,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2198,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"components/Map/DawarichTrailLayer.tsx": 1,
|
||||
"components/Map/dawarichTrail.ts": 10,
|
||||
"components/Map/useDawarichTrailGL.ts": 1,
|
||||
"components/Packing/PackingListPanelItemRow.tsx": 1,
|
||||
"components/Packing/PackingBringersCard.tsx": 1,
|
||||
"components/Planner/DayReorderPopup.tsx": 1,
|
||||
"components/Planner/bookings/bookingsModel.ts": 1,
|
||||
"components/Planner/bookings/transitParts.tsx": 1,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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';
|
||||
@@ -71,8 +73,9 @@ 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 = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' });
|
||||
const date = formatDisplayDate(new Date(request.requested_at), language, { dateStyle: 'medium' }, dateFormat);
|
||||
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,7 +1,9 @@
|
||||
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,
|
||||
@@ -258,9 +260,10 @@ 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
|
||||
? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' })
|
||||
? formatDisplayDate(new Date(region.source_timestamp), language, { dateStyle: 'medium' }, dateFormat)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-STOR-RECLAIM-001 to -007: the reclaim report as both storage panels show it.
|
||||
// FE-ADMIN-STOR-RECLAIM-001 to -009: 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,4 +137,84 @@ 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,11 +67,23 @@ function freedBy(run: StorageReclaimReport | null): ReclaimCount {
|
||||
);
|
||||
}
|
||||
|
||||
type SkipReason = StorageReclaimCategoryReport['skipped'];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
@@ -82,7 +94,7 @@ function lineOf(
|
||||
report: StorageReclaimCategoryReport | undefined
|
||||
): ReclaimCategoryLine | null {
|
||||
if (!report || report.skipped === 'not_reclaimed') return null;
|
||||
const judged = report.skipped === null || report.skipped === 'interrupted' || report.skipped === 'delete_failed';
|
||||
const judged = !NEVER_JUDGED.has(report.skipped);
|
||||
// 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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal, Fuel, ParkingCircle } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
|
||||
import { COST_CATEGORIES, costCategoryOf, type CostCategory } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The fixed Costs categories. Users can't add their own — every expense maps to
|
||||
@@ -34,34 +34,7 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||
|
||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||
|
||||
/**
|
||||
* Legacy / English free-text categories (and reservation type labels) mapped to
|
||||
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
|
||||
* which never matched the lowercase keys and fell through to `other`.
|
||||
*/
|
||||
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
||||
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
|
||||
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
|
||||
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport', cable_car: 'transport',
|
||||
transport: 'transport', transportation: 'transport',
|
||||
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
|
||||
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
|
||||
grocery: 'groceries', groceries: 'groceries',
|
||||
activity: 'activities', activities: 'activities',
|
||||
sightseeing: 'sightseeing', sights: 'sightseeing',
|
||||
shop: 'shopping', shopping: 'shopping',
|
||||
fee: 'fees', fees: 'fees',
|
||||
health: 'health', medical: 'health',
|
||||
tip: 'tips', tips: 'tips',
|
||||
gas: 'fuel', fuel: 'fuel', petrol: 'fuel',
|
||||
parking: 'parking', parkings: 'parking', 'car park': 'parking',
|
||||
other: 'other', misc: 'other',
|
||||
}
|
||||
|
||||
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
|
||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||
if (!cat) return COST_CAT_META.other
|
||||
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
|
||||
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
|
||||
return COST_CAT_META[costCategoryOf(cat) ?? 'other']
|
||||
}
|
||||
|
||||
@@ -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' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}>
|
||||
<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}>
|
||||
{cur.icon ?? lead}
|
||||
<span className="col-filter-lbl">{cur.label}</span>
|
||||
<ChevronDown size={14} className="col-filter-chev" />
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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 from 'react'
|
||||
import { Plus, Settings2, Tags } from 'lucide-react'
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, Plus, Settings2, Tags } from 'lucide-react'
|
||||
import type { LabelOption } from '../../pages/collections/collectionsModel'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
@@ -19,54 +19,154 @@ 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, where there is room to spare; 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; 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.
|
||||
*/
|
||||
export default function CollectionLabelFilter({
|
||||
labelOptions, labelFilter, onLabelFilter, canManageLabels = false, onManageLabels,
|
||||
variant = 'bar', t,
|
||||
}: CollectionLabelFilterProps): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`col-labelfilter${variant === 'map' ? ' on-map' : ''}`}
|
||||
role="group"
|
||||
aria-label={t('collections.labels.manage')}
|
||||
>
|
||||
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" />
|
||||
{labelOptions.map(l => {
|
||||
const on = labelFilter.includes(l.id)
|
||||
return (
|
||||
<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 : (
|
||||
<>
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
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}
|
||||
onClick={() => fold.setOpen(o => !o)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={fold.open}
|
||||
className={`col-labelfilter on-map col-labelfilter-fold${fold.open ? ' open' : ''}`}
|
||||
>
|
||||
<span className="col-labelchip-dot" />
|
||||
<span className="col-filter-lbl">{l.name}</span>
|
||||
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
|
||||
<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" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<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${active || selected ? ' sel' : ''}`}
|
||||
className={`col-lrow col-lrow-place${active || selected ? ' sel' : ''}`}
|
||||
>
|
||||
{selectMode ? (
|
||||
<span className={`col-lcheck${selected ? ' on' : ''}`}>{selected && <Check size={14} strokeWidth={3} />}</span>
|
||||
@@ -67,24 +67,8 @@ export default function CollectionList({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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 />
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
<CollectionPlaceTags place={place} labels={placeLabels} />
|
||||
<div className="col-lrow-status">
|
||||
<StatusBadge status={place.status} onChange={selectMode || !onStatusChange ? undefined : next => onStatusChange(place.id, next)} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
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,6 +1,8 @@
|
||||
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';
|
||||
@@ -26,6 +28,7 @@ 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);
|
||||
@@ -43,7 +46,12 @@ function DayNotices({ state, prep }: { state: JourneyDayImportState; prep?: Jour
|
||||
{review.syncedAt
|
||||
? t('journey.dayImport.stale', {
|
||||
reason,
|
||||
date: new Date(review.syncedAt).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }),
|
||||
date: formatDisplayDateTime(
|
||||
new Date(review.syncedAt),
|
||||
locale,
|
||||
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||
dateFormat
|
||||
),
|
||||
})
|
||||
: t('journey.dayImport.staleNever', { reason })}
|
||||
</Notice>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// FE-JRN-OPENAT-001 to FE-JRN-OPENAT-005
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { journeyOpenAtEntryId } from './journeyOpenAt';
|
||||
|
||||
const entries = [
|
||||
{ id: 3, entry_date: '2026-05-03' },
|
||||
{ id: 1, entry_date: '2026-05-01' },
|
||||
{ id: 2, entry_date: '2026-05-01' },
|
||||
{ id: 5, entry_date: '2026-05-05' },
|
||||
];
|
||||
|
||||
describe('journeyOpenAtEntryId', () => {
|
||||
it('FE-JRN-OPENAT-001: a running journey opens on the first entry of today', () => {
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03' })).toBe('3');
|
||||
});
|
||||
|
||||
it('FE-JRN-OPENAT-002: a rest day lands on the last day written before it', () => {
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-05-04' })).toBe('3');
|
||||
});
|
||||
|
||||
it('FE-JRN-OPENAT-003: a journey that is over, or not begun, opens at its start', () => {
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01' })).toBeNull();
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-04-01' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-JRN-OPENAT-004: newest-first opens on the latest day, also after the trip', () => {
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01', newestFirst: true })).toBe('5');
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03', newestFirst: true })).toBe('5');
|
||||
});
|
||||
|
||||
it('FE-JRN-OPENAT-005: an empty journey has nothing to open on', () => {
|
||||
expect(journeyOpenAtEntryId([], { today: '2026-05-03', newestFirst: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
interface DatedEntry {
|
||||
id: number | string;
|
||||
entry_date: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry a journey opens on, or null for its first one.
|
||||
*
|
||||
* Every load used to start at the first entry, so keeping a journal on a trip
|
||||
* meant scrolling past everything already written before you could add to it
|
||||
* (discussion #2299). "Today" means the last day at or before now: a rest day
|
||||
* with nothing written still lands you at yesterday rather than at the start.
|
||||
*
|
||||
* Only while the journey is running, though. A journey that ended last spring
|
||||
* is something you read, and reading starts at the beginning; opening it at the
|
||||
* last page would be the worse of the two mistakes.
|
||||
*
|
||||
* A link the owner published newest-first reads like a blog (#1614), so it
|
||||
* opens on the latest day whether or not the trip is still running (#2851).
|
||||
*
|
||||
* Always the first entry OF the chosen day, not the last one before now: a day
|
||||
* is read from its beginning.
|
||||
*/
|
||||
export function journeyOpenAtEntryId(
|
||||
entries: readonly DatedEntry[],
|
||||
{ today, newestFirst = false }: { today: string; newestFirst?: boolean }
|
||||
): string | null {
|
||||
const dated = [...entries].sort((a, b) => a.entry_date.localeCompare(b.entry_date));
|
||||
const first = dated[0];
|
||||
const last = dated[dated.length - 1];
|
||||
if (!first || !last) return null;
|
||||
let day = last.entry_date;
|
||||
if (!newestFirst) {
|
||||
if (today < first.entry_date || today > last.entry_date) return null;
|
||||
const reached = dated.filter((e) => e.entry_date <= today);
|
||||
day = reached[reached.length - 1]?.entry_date ?? first.entry_date;
|
||||
}
|
||||
const dayStart = dated.find((e) => e.entry_date === day) ?? first;
|
||||
return String(dayStart.id);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-008: the card carousel over the journey map,
|
||||
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-009: the card carousel over the journey map,
|
||||
// behind both the phone journey screen and the narrow desktop and public timeline.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -145,4 +145,43 @@ describe('useJourneyCarouselSync', () => {
|
||||
act(() => result.current.openEntryById('99'));
|
||||
expect(props.onOpenEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-JRN-CAROUSEL-009: a carousel scrolled to either end settles on the card at that end (#2851)', () => {
|
||||
const carousel = document.createElement('div');
|
||||
carousel.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
|
||||
Object.defineProperty(carousel, 'clientWidth', { value: 300 });
|
||||
Object.defineProperty(carousel, 'scrollWidth', { value: 600 });
|
||||
const hook = renderHook(
|
||||
(p: Options) => {
|
||||
const sync = useJourneyCarouselSync(p);
|
||||
(sync.carouselRef as { current: HTMLDivElement | null }).current = carousel;
|
||||
return sync;
|
||||
},
|
||||
{ initialProps: { entries: [], mapEntries: [], onOpenEntry: vi.fn() } }
|
||||
);
|
||||
hook.rerender({ entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn() });
|
||||
// Card 1 sits at the centre (150) in both positions: only the ends decide.
|
||||
[0, 100, 200].forEach((left, i) => {
|
||||
const node = document.createElement('div');
|
||||
node.getBoundingClientRect = () => ({ left, width: 100 }) as DOMRect;
|
||||
hook.result.current.cardRefs.current.set(i, node);
|
||||
});
|
||||
const settleAt = (scrollLeft: number) => {
|
||||
Object.defineProperty(carousel, 'scrollLeft', { value: scrollLeft, configurable: true });
|
||||
carousel.dispatchEvent(new Event('scroll'));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
};
|
||||
|
||||
settleAt(300);
|
||||
expect(hook.result.current.activeIndex).toBe(2);
|
||||
settleAt(150);
|
||||
expect(hook.result.current.activeIndex).toBe(1);
|
||||
// Right to left: the scroll position runs negative from the start.
|
||||
settleAt(-300);
|
||||
expect(hook.result.current.activeIndex).toBe(2);
|
||||
settleAt(0);
|
||||
expect(hook.result.current.activeIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,14 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
bestIdx = idx;
|
||||
}
|
||||
});
|
||||
// The first and the last card can never reach the centre, so a carousel scrolled
|
||||
// as far as it goes means the card at that end: without this, opening on the
|
||||
// latest day settled on the one before it (#2851). Measured from the start in
|
||||
// either direction: in a right-to-left layout scrollLeft runs negative.
|
||||
const travel = el.scrollWidth - el.clientWidth;
|
||||
const scrolled = Math.abs(el.scrollLeft);
|
||||
if (travel > 1 && scrolled >= travel - 1) bestIdx = cardRefs.current.size - 1;
|
||||
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
|
||||
setActiveIndex((prev) => {
|
||||
if (prev !== bestIdx) syncMapToCard(bestIdx);
|
||||
return bestIdx;
|
||||
|
||||
@@ -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 { useLngFrame } from './lngFrameContext'
|
||||
import { frameLng } from '../../utils/lngFrame'
|
||||
import { frameLng, type LngFrame } from '../../utils/lngFrame'
|
||||
|
||||
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
|
||||
export default function ClusteredPois({ pois, frame, 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, enabled, onPoiClick, children }: {
|
||||
map.on('zoomend', update)
|
||||
return () => { map.off('zoomend', update) }
|
||||
}, [map])
|
||||
// Measured and placed where the map draws them, in its longitude frame (#2975).
|
||||
// Measured and placed where the markers are drawn, on the world copy in view (#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,6 +43,7 @@ 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[] = []
|
||||
@@ -483,4 +484,30 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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, makePoiDraggable } from './markerDrag'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
||||
@@ -55,11 +55,10 @@ 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 ClusteredPois from './ClusteredPois'
|
||||
import PoiMarkers from './PoiMarkers'
|
||||
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'
|
||||
@@ -228,59 +227,6 @@ 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
|
||||
@@ -761,8 +707,6 @@ 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
|
||||
@@ -774,30 +718,6 @@ 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])
|
||||
@@ -1223,7 +1143,7 @@ export const MapView = memo(function MapView({
|
||||
hiddenDates={dawarichHiddenDates}
|
||||
casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined}
|
||||
/>
|
||||
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
|
||||
<PoiMarkers pois={pois as Poi[]} clusterLoosely={clusterLoosely} onPoiClick={onPoiClick} draggable={!!onPoiDropOnRoute} />
|
||||
{/* 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) => (
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -169,6 +169,45 @@ describe('buildDayRouteRuns', () => {
|
||||
expect(hotelBookendOf(run[0], run[1])).toBe('morning')
|
||||
})
|
||||
|
||||
it("FE-MAP-DRP-017: the morning after a night away starts at the side trip's hotel, not the room kept at home", () => {
|
||||
// The first hotel stays booked over the whole trip and comes first in the list; the
|
||||
// side trip's hotel holds the night before. The day used to start with a 716 km drive
|
||||
// from the kept room to the side trip's first stop.
|
||||
const days = [1, 2, 3, 4, 5].map(n => buildDay({ id: n, day_number: n }))
|
||||
const kept = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 5 } as unknown as Accommodation
|
||||
const away = { id: 2, trip_id: 1, place_lat: 18.7883, place_lng: 98.9853, start_day_id: 2, end_day_id: 3 } as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(3, inputs({
|
||||
days,
|
||||
assignments: { '3': [buildAssignment({ day_id: 3, order_index: 0, place: buildPlace({ lat: 18.8048, lng: 98.9216, place_time: '10:00' }) })] },
|
||||
accommodations: [kept, away],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
expect(runs[0][0]).toMatchObject({ lat: 18.7883, lng: 98.9853, hotel: 'morning' })
|
||||
expect(runs.flat().some(p => p.hotel === 'morning' && p.lat === 13.7466)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-018: a taxi that leaves from the hotel adds no road from the hotel to itself', () => {
|
||||
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
|
||||
const hotel = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 3 } as unknown as Accommodation
|
||||
const taxi = {
|
||||
id: 9, trip_id: 1, type: 'taxi', title: 'Taxi', status: 'confirmed', day_id: 2, end_day_id: 2,
|
||||
reservation_time: '2026-12-02T09:00', reservation_end_time: '2026-12-02T09:40',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Hotel', lat: 13.7466, lng: 100.5393 },
|
||||
{ role: 'to', sequence: 1, name: 'Museum', lat: 13.7516, lng: 100.4927 },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
const runs = buildDayRouteRuns(2, inputs({
|
||||
days,
|
||||
assignments: { '2': [buildAssignment({ day_id: 2, order_index: 0, place: buildPlace({ lat: 13.7563, lng: 100.5018, place_time: '12:00' }) })] },
|
||||
reservations: [taxi],
|
||||
accommodations: [hotel],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
const zeroLength = runs.filter(run => run.length === 2 && run[0].lat === run[1].lat && run[0].lng === run[1].lng)
|
||||
expect(zeroLength).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
|
||||
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
|
||||
@@ -196,7 +196,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
const role: CarrierEdge = side === 'first'
|
||||
? (e.from ? 'departure' : 'arrival')
|
||||
: (e.to ? 'arrival' : 'departure')
|
||||
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
|
||||
// The booking's own point at this edge of the day, as the sidebar passes it: a taxi
|
||||
// that starts or ends at the hotel then adds no zero-length leg to it.
|
||||
const point = side === 'first' ? (e.from ?? e.to) : (e.to ?? e.from)
|
||||
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null, lat: point?.lat, lng: point?.lng }
|
||||
}
|
||||
// A carrier with a located endpoint today means the day changes geography by
|
||||
// air/rail/sea — the check-in hotel is then the day's destination and the
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
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,13 +379,16 @@ describe('ArtikelZeile — category picker', () => {
|
||||
})
|
||||
|
||||
describe('ArtikelZeile — sharing badges', () => {
|
||||
it('FE-W5ROW-018: an item somebody else brings shows their name', () => {
|
||||
it('FE-W5ROW-018: an item somebody else brings shows their name in the TREK tooltip', async () => {
|
||||
setup({
|
||||
item: buildPackingItem({ id: 1, name: 'Stove', is_private: 1, owner_id: 2, owner_username: 'Bob' } as Partial<PackingItem>),
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('by Bob')).toHaveAttribute('title', 'by Bob')
|
||||
const badge = screen.getByLabelText('by Bob')
|
||||
expect(badge).not.toHaveAttribute('title')
|
||||
fireEvent.mouseEnter(badge)
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('by Bob')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-019: a nameless bringer degrades to an empty name', () => {
|
||||
@@ -394,10 +397,10 @@ describe('ArtikelZeile — sharing badges', () => {
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('by')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ROW-020: an item shared by me counts its recipients', () => {
|
||||
it('FE-W5ROW-020: an item shared by me counts its recipients', async () => {
|
||||
setup({
|
||||
item: buildPackingItem({
|
||||
id: 1, name: 'Stove', is_private: 1, owner_id: 1,
|
||||
@@ -407,10 +410,11 @@ describe('ArtikelZeile — sharing badges', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('shared with 2')).toHaveTextContent('2')
|
||||
expect(screen.getByTitle('shared with 2: alice, bob')).toBeInTheDocument()
|
||||
fireEvent.mouseEnter(screen.getByLabelText('shared with 2'))
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('shared with 2: alice, bob')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-021: a common item names its bringer and counts co-bringers', () => {
|
||||
it('FE-W5ROW-021: a common item names its bringer and counts co-bringers', async () => {
|
||||
setup({
|
||||
item: buildPackingItem({
|
||||
id: 1, name: 'Stove', is_private: 0, owner_username: 'Bob',
|
||||
@@ -419,8 +423,15 @@ describe('ArtikelZeile — sharing badges', () => {
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
// The bringer's initial on an avatar, the co-bringers as a count.
|
||||
expect(screen.getByTitle('brought by Bob')).toHaveTextContent('B+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')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-022: a bringer without co-bringers shows no counter', () => {
|
||||
@@ -429,7 +440,7 @@ describe('ArtikelZeile — sharing badges', () => {
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('brought by Bob')).toHaveTextContent(/^B$/)
|
||||
expect(screen.getByLabelText('brought by Bob')).toHaveTextContent(/^B$/)
|
||||
})
|
||||
|
||||
it('FE-W5ROW-023: the placeholder row carries no badges and no share control', () => {
|
||||
|
||||
@@ -18,6 +18,9 @@ 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'
|
||||
|
||||
interface ArtikelZeileProps {
|
||||
item: PackingItem
|
||||
@@ -77,7 +80,6 @@ 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)
|
||||
@@ -113,8 +115,7 @@ 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 owner = tripMembers.find(m => m.id === item.owner_id)
|
||||
const ownerAvatar = owner?.avatar_url ?? owner?.avatar ?? null
|
||||
const bringers = bringersOf(item, tripMembers)
|
||||
|
||||
// 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
|
||||
@@ -214,31 +215,29 @@ 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 && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{!isPlaceholder && sharedByMe && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{!isPlaceholder && broughtBy && (
|
||||
<span className="packing-row-badge" title={t('packing.broughtBy', { name: broughtBy })} aria-label={t('packing.broughtBy', { name: broughtBy })}
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="packing-row-inline-actions" style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// FE-PACK-BRINGERS-001 to FE-PACK-BRINGERS-003
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { packingBringerNames } from './packingBringers';
|
||||
|
||||
describe('packingBringerNames', () => {
|
||||
it('FE-PACK-BRINGERS-001: names the bringer first, then everyone who also brings it', () => {
|
||||
const item = {
|
||||
owner_username: 'anna',
|
||||
contributors: [{ username: 'ben' }, { username: 'carla' }],
|
||||
};
|
||||
expect(packingBringerNames(item)).toBe('anna, ben, carla');
|
||||
});
|
||||
|
||||
it('FE-PACK-BRINGERS-002: names a person once, even when listed twice', () => {
|
||||
expect(packingBringerNames({ owner_username: 'anna', contributors: [{ username: 'anna' }] })).toBe('anna');
|
||||
});
|
||||
|
||||
it('FE-PACK-BRINGERS-003: copes with an item without a bringer or contributors', () => {
|
||||
expect(packingBringerNames({ owner_username: null, contributors: [{ username: 'ben' }] })).toBe('ben');
|
||||
expect(packingBringerNames({})).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { TripMember } from './usePackingListPanel';
|
||||
|
||||
interface BroughtItem {
|
||||
owner_username?: string | null;
|
||||
contributors?: { username: string }[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone bringing a common item, as one readable list: its bringer first, then
|
||||
* whoever also brings it. The row only has room for one avatar and a "+n", so this
|
||||
* is what its tooltip and the item sheet name (#2782).
|
||||
*/
|
||||
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,8 +1122,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const costsEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const totalCostLabel = useMemo(() => {
|
||||
const costs = planCosts({ days, assignments, reservations, budgetItems, costsEnabled, tripCurrency: currency })
|
||||
return formatMoneySum(costs.total, costBase, locale, fxRates)
|
||||
return formatMoneySum(planCosts({ days, assignments, reservations, budgetItems, costsEnabled, tripCurrency: currency, displayCurrency: costBase }).total, costBase, locale, fxRates)
|
||||
}, [days, assignments, reservations, budgetItems, costsEnabled, currency, costBase, locale, fxRates])
|
||||
|
||||
return {
|
||||
|
||||
@@ -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} />
|
||||
<TourGpxPreviewDialog gpxImport={tourFileImport} offerPoints />
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
{categoryPickerOpen && (
|
||||
<PlacesBulkCategoryModal
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByRole('button', { name: /^Accommodation$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Restaurant$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Event$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Tour$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Guided tour$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Parking$/i })).toBeInTheDocument();
|
||||
// The pill reads "Booking Type: Other", so only its option is named plain "Other".
|
||||
expect(screen.getAllByRole('button', { name: /^Other$/i })).toHaveLength(1);
|
||||
@@ -708,7 +708,7 @@ describe('ReservationModal', () => {
|
||||
|
||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await pickType(/^Tour$/i);
|
||||
await pickType(/^Guided tour$/i);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('BookingsTimeline: trip view', () => {
|
||||
expect(screen.getByText('No date')).toBeInTheDocument();
|
||||
const metro = screen.getByRole('button', { name: /Metro ride/ });
|
||||
expect(metro).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: /Bonus tour/ })).toHaveTextContent('Tour, Pending');
|
||||
expect(screen.getByRole('button', { name: /Bonus tour/ })).toHaveTextContent('Guided tour, Pending');
|
||||
expect(screen.getByRole('button', { name: /Farewell dinner/ })).toHaveTextContent('Restaurant, Confirmed');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Farewell dinner/ }));
|
||||
expect(onSelect).toHaveBeenCalledWith(early);
|
||||
|
||||
@@ -65,6 +65,19 @@ 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 } from '../../hooks/useExchangeRates'
|
||||
import { bookedInTrip, inDisplayCurrency } 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,18 +22,22 @@ 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.
|
||||
* 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.
|
||||
*
|
||||
* 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 }: {
|
||||
export function planCosts({ days, assignments, reservations, budgetItems, costsEnabled, tripCurrency, displayCurrency }: {
|
||||
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>()
|
||||
@@ -84,6 +88,10 @@ 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,6 +104,24 @@ 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,6 +6,8 @@ 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'
|
||||
@@ -89,13 +91,22 @@ export function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.").
|
||||
* 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.").
|
||||
*/
|
||||
function shortDate(iso: string, is12h: boolean): string {
|
||||
function shortDate(iso: string, is12h: boolean, format?: string | null): string {
|
||||
const [, m, d] = iso.split("-").map(Number)
|
||||
if (!m || !d) return iso
|
||||
return is12h ? `${m}/${d}` : `${d}.${m}.`
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +150,7 @@ 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"
|
||||
@@ -154,7 +166,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)}
|
||||
{shortDate(day.date, is12h, dateFormat)}
|
||||
</time>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-007
|
||||
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-009
|
||||
// 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} />);
|
||||
render(<TourGpxPreviewDialog gpxImport={current} offerPoints />);
|
||||
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} />);
|
||||
render(<TourGpxPreviewDialog gpxImport={current} offerPoints />);
|
||||
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} />);
|
||||
render(<TourGpxPreviewDialog gpxImport={current} offerPoints />);
|
||||
const button = screen.getByTestId('gpx-preview-import');
|
||||
expect(button).toBeEnabled();
|
||||
expect(button).toHaveTextContent(/tours\.import\.pointsConfirm|Add places/);
|
||||
@@ -113,4 +113,20 @@ 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. On by default: a file with
|
||||
* a route and its stops is usually meant as both.
|
||||
* tour is made of, offered as places of the trip where the dialog's caller
|
||||
* asks for it.
|
||||
*/
|
||||
function PreviewPoints({ count, on, onToggle }: { count: number; on: boolean; onToggle: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -183,18 +183,22 @@ 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).
|
||||
* 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.
|
||||
*/
|
||||
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} />;
|
||||
return <PreviewDialog gpxImport={gpxImport} files={preview} initial={initial ?? HIKE} offerPoints={offerPoints} />;
|
||||
}
|
||||
|
||||
const HIKE: TourGpxImportFields = { tour_type: 'hike' };
|
||||
@@ -207,16 +211,20 @@ 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 (
|
||||
<>
|
||||
{leftOut.points > 0 && <PreviewPoints count={leftOut.points} on={withPoints} onToggle={onTogglePoints} />}
|
||||
{offerPoints && leftOut.points > 0 && (
|
||||
<PreviewPoints count={leftOut.points} on={withPoints} onToggle={onTogglePoints} />
|
||||
)}
|
||||
<PreviewLeftOut leftOut={withPoints ? { ...leftOut, points: 0 } : leftOut} />
|
||||
</>
|
||||
);
|
||||
@@ -226,17 +234,20 @@ 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]);
|
||||
const [withPoints, setWithPoints] = useState(true);
|
||||
// On by default where offered: a file with a route and its stops is usually meant as both.
|
||||
const [withPoints, setWithPoints] = useState(offerPoints);
|
||||
const addsPoints = withPoints && leftOut.points > 0;
|
||||
|
||||
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
|
||||
@@ -314,7 +325,12 @@ function PreviewDialog({
|
||||
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} number={index + 1} track={track} />
|
||||
))}
|
||||
</ul>
|
||||
<PreviewExtras leftOut={leftOut} withPoints={withPoints} onTogglePoints={() => setWithPoints((on) => !on)} />
|
||||
<PreviewExtras
|
||||
leftOut={leftOut}
|
||||
offerPoints={offerPoints}
|
||||
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').mockResolvedValue({ count: 3 })
|
||||
const pinsCall = vi.spyOn(placesApi, 'importMapFile')
|
||||
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')
|
||||
// The pins are offered as places (#2969); only the area stays out.
|
||||
expect(screen.getByTestId('gpx-preview-points')).toHaveTextContent('3 pins')
|
||||
// Only the places column offers the pins as places (#2969); here they stay out.
|
||||
expect(screen.queryByTestId('gpx-preview-points')).toBeNull()
|
||||
const leftOut = screen.getByTestId('gpx-preview-left-out')
|
||||
expect(leftOut).toHaveTextContent('Not imported')
|
||||
expect(leftOut).not.toHaveTextContent('pins')
|
||||
expect(leftOut).toHaveTextContent('3 pins')
|
||||
expect(leftOut).toHaveTextContent('1 area')
|
||||
expect(leftOut).not.toHaveTextContent('other item')
|
||||
expect(within(leftOut).getByRole('button', { name: 'Not imported' })).toBeInTheDocument()
|
||||
@@ -483,8 +483,7 @@ 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).toHaveBeenCalledWith(1, file, { points: true, paths: false })
|
||||
expect(toast.success).toHaveBeenCalledWith('3 places added')
|
||||
expect(pinsCall).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ 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';
|
||||
@@ -306,9 +307,10 @@ function decidedLine(
|
||||
decision: ObstacleDecisionRecord,
|
||||
memberName: (id: number | null) => string | null,
|
||||
t: Translate,
|
||||
locale: string
|
||||
locale: string,
|
||||
dateFormat: string
|
||||
): string {
|
||||
const date = formatCheckDate(decision.decided_at, locale);
|
||||
const date = formatCheckDate(decision.decided_at, locale, dateFormat);
|
||||
const name = memberName(decision.decided_by);
|
||||
return name ? t('tours.paddle.check.decidedBy', { date, name }) : t('tours.paddle.check.decidedAt', { date });
|
||||
}
|
||||
@@ -330,10 +332,11 @@ 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)}</span>}
|
||||
{decision && <span className="min-w-0">{decidedLine(decision, memberName, t, locale, dateFormat)}</span>}
|
||||
{decision && clearable && (
|
||||
<button type="button" onClick={onClear} disabled={disabled} className={QUIET}>
|
||||
{t('tours.paddle.check.clear')}
|
||||
@@ -386,6 +389,7 @@ 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}`);
|
||||
@@ -421,7 +425,9 @@ export function ObstacleDecisionPanel({
|
||||
<FigureBadge
|
||||
testId="obstacle-on-site"
|
||||
label={t('tours.paddle.check.onSite')}
|
||||
value={entry.local_check ? formatCheckDate(entry.local_check, locale) : t('tours.paddle.check.unknown')}
|
||||
value={
|
||||
entry.local_check ? formatCheckDate(entry.local_check, locale, dateFormat) : t('tours.paddle.check.unknown')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{entry.decision && <NoteBlock key={entry.key} note={entry.decision.note} disabled={locked} onNote={onNote} />}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -77,8 +78,9 @@ 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) : null;
|
||||
const asOf = check.data_as_of ? formatCheckDate(check.data_as_of, locale, dateFormat) : null;
|
||||
const coverage =
|
||||
check.data === 'covered' ? null : COVERAGE_INFO[check.data](t, asOf ?? t('tours.paddle.obstacles.dateUnknown'));
|
||||
return (
|
||||
@@ -103,7 +105,7 @@ export function PaddleCheckDataLine({ check }: { check: PaddleCheck }) {
|
||||
<FigureBadge
|
||||
testId="paddle-check-read"
|
||||
label={t('tours.paddle.check.readDate')}
|
||||
value={formatCheckDate(check.read_at, locale)}
|
||||
value={formatCheckDate(check.read_at, locale, dateFormat)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -81,8 +82,9 @@ 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) : '';
|
||||
const time = evaluatedAt ? formatCheckDate(evaluatedAt, locale, dateFormat) : '';
|
||||
return (
|
||||
<TourNotice role={role} icon={icon}>
|
||||
{t(textKey, { time })}
|
||||
@@ -311,11 +313,12 @@ 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)
|
||||
? formatCheckDate(summary.data_as_of, locale, dateFormat)
|
||||
: t('tours.paddle.obstacles.dateUnknown');
|
||||
text = t('tours.paddle.obstacles.coveredEmpty', { date });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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';
|
||||
@@ -86,7 +88,10 @@ 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 date = detail.dataAsOf ? new Date(detail.dataAsOf).toLocaleDateString(language, { dateStyle: 'medium' }) : null;
|
||||
const dateFormat = useDateFormat();
|
||||
const date = detail.dataAsOf
|
||||
? formatDisplayDate(new Date(detail.dataAsOf), language, { dateStyle: 'medium' }, dateFormat)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
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): string | null {
|
||||
function dataDate(
|
||||
regions: readonly { source_timestamp?: string | null }[],
|
||||
language: string,
|
||||
dateFormat: string
|
||||
): string | null {
|
||||
const times = regions
|
||||
.map((region) => region.source_timestamp)
|
||||
.filter((time): time is string => !!time)
|
||||
.sort(compareCodeUnits);
|
||||
return times[0] ? new Date(times[0]).toLocaleDateString(language, { dateStyle: 'medium' }) : null;
|
||||
return times[0] ? formatDisplayDate(new Date(times[0]), language, { dateStyle: 'medium' }, dateFormat) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +29,7 @@ function dataDate(regions: readonly { source_timestamp?: string | null }[], lang
|
||||
*/
|
||||
export function PaddleObstacleNotice() {
|
||||
const { t, language } = useTranslation();
|
||||
const dateFormat = useDateFormat();
|
||||
const { status, coverage, related, regions, truncated } = usePaddleObstacleStore(
|
||||
useShallow((state) => ({
|
||||
status: state.status,
|
||||
@@ -43,7 +50,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) ?? t('tours.paddle.obstacles.dateUnknown');
|
||||
const date = dataDate(regions, language, dateFormat) ?? 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-019: the check bench of a paddle tour in one pill, how the dates of the checklist read, where the
|
||||
// PCM-001 to PCM-020: 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,6 +146,13 @@ 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,6 +7,7 @@ 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;
|
||||
|
||||
@@ -52,8 +53,11 @@ 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. */
|
||||
export function formatCheckDate(value: string, locale: string): string {
|
||||
/**
|
||||
* 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 {
|
||||
const month = MONTH.exec(value);
|
||||
try {
|
||||
if (month) {
|
||||
@@ -64,7 +68,7 @@ export function formatCheckDate(value: string, locale: string): 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 new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone }).format(date);
|
||||
return formatDisplayDate(date, locale, { dateStyle: 'medium', timeZone }, format);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -131,4 +131,31 @@ 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,22 +78,33 @@ 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<number> {
|
||||
let places = 0;
|
||||
): Promise<PointsImported> {
|
||||
const sum: PointsImported = { places: 0, skipped: 0 };
|
||||
for (const { file, leftOut } of files) {
|
||||
if (!leftOut?.points) continue;
|
||||
try {
|
||||
places += await tourRepo.importFilePoints(tripId, file);
|
||||
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);
|
||||
} catch {
|
||||
onError(file);
|
||||
}
|
||||
}
|
||||
return places;
|
||||
return sum;
|
||||
}
|
||||
|
||||
interface TourGpxImportOptions {
|
||||
@@ -180,11 +191,12 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported,
|
||||
setImporting(true);
|
||||
try {
|
||||
const { tours: imported, skipped, caution } = await importTours(tripId, preview, fields, fileError);
|
||||
const places = extras.points
|
||||
const points = extras.points
|
||||
? await importPoints(tripId, preview, (file) =>
|
||||
toast.error(t('tours.import.pointsFailedIn', { name: file.name }))
|
||||
)
|
||||
: 0;
|
||||
: { places: 0, skipped: 0 };
|
||||
const places = points.places;
|
||||
setPreview(null);
|
||||
if (places > 0) {
|
||||
if (imported.length === 0) await refreshPlaces(tripId);
|
||||
@@ -200,10 +212,14 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported,
|
||||
if (caution) toast.info(t('tours.import.caution'));
|
||||
onImported?.(imported);
|
||||
}
|
||||
if (skipped > 0) {
|
||||
toast.warning(
|
||||
imported.length > 0 ? t('tours.import.skipped', { count: skipped }) : t('tours.import.allSkipped')
|
||||
);
|
||||
// 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'));
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-W4TIP-001 to FE-W4TIP-017
|
||||
// FE-W4TIP-001 to FE-W4TIP-018
|
||||
import { createRef, useRef } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
|
||||
@@ -213,3 +213,14 @@ 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,10 +11,15 @@ 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, children }: TooltipProps) {
|
||||
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, shortcut, content, children }: TooltipProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
@@ -93,8 +98,8 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, sh
|
||||
zIndex: 100000,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
padding: '5px 10px',
|
||||
borderRadius: 8,
|
||||
padding: content ? '8px 10px' : '5px 10px',
|
||||
borderRadius: content ? 10 : 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,
|
||||
@@ -103,12 +108,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',
|
||||
}}
|
||||
>
|
||||
{shortcut && shortcut.length > 0 ? (
|
||||
{content ?? (shortcut && shortcut.length > 0 ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{label}
|
||||
<Kbd keys={shortcut} />
|
||||
</span>
|
||||
) : label}
|
||||
) : label)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,9 @@ 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';
|
||||
@@ -111,6 +113,7 @@ 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' : ''}`}>
|
||||
@@ -134,7 +137,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}>
|
||||
{new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' })}
|
||||
{formatDisplayDate(new Date(request.requested_at), language, { dateStyle: 'medium' }, dateFormat)}
|
||||
</MBadge>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end gap-1.5">
|
||||
@@ -269,13 +272,14 @@ 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
|
||||
? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' })
|
||||
? formatDisplayDate(new Date(region.source_timestamp), language, { dateStyle: 'medium' }, dateFormat)
|
||||
: null;
|
||||
const km = region.network
|
||||
? new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(region.network.water_km)
|
||||
|
||||
@@ -192,7 +192,8 @@ 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. Share place details has its own section there. */}
|
||||
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. */}
|
||||
{!managed && (
|
||||
<MAdminCard>
|
||||
<MAdminCardHead title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')} />
|
||||
@@ -204,9 +205,6 @@ 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. */}
|
||||
@@ -325,7 +323,6 @@ 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
|
||||
@@ -378,11 +375,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!managed && (
|
||||
<MAdminButton busy={savingKeys} onClick={handleSaveApiKeys}>
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
)}
|
||||
<MAdminButton busy={savingKeys} onClick={handleSaveApiKeys}>
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
</MAdminCard>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { toDisplayTemp } from '../../../../components/Planner/dayDetailModel'
|
||||
import { getDayBookendHotels } from '../../../../utils/dayOrder'
|
||||
import { dayWeatherAnchor } from '../../../../utils/dayWeather'
|
||||
import { metadataWithLegPositions, planMergedOrder, withTransportPositions } from '../../../../utils/mergedOrder'
|
||||
import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEndpointOnDay, isCarrierTransport, stopDayIds } from '../../../../utils/dayMerge'
|
||||
import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEndpointOnDay, isCarrierTransport, stopDayIds, yieldTimedBookings } from '../../../../utils/dayMerge'
|
||||
import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl, optimizeDayOrder, type DayCarrier } from '../../../../components/Planner/dayRoute'
|
||||
import type { PlannedTransitLeg, TransitLeg } from '../../../../components/Planner/transitLeg'
|
||||
import { useTransitLegPlanner } from '../../../../components/Planner/useTransitLegPlanner'
|
||||
@@ -208,10 +208,17 @@ export function useMPlanTimeline(planner: TripPlanner) {
|
||||
const newOrder = [...merged]
|
||||
newOrder.splice(idx, 1)
|
||||
newOrder.splice(toIndex, 0, item)
|
||||
// Same guard as the desktop arrow buttons: a TIMED item may not jump past
|
||||
// other timed items (untimed items move freely and keep their neighbours).
|
||||
// A TIMED item may not jump past other timed items (untimed items move freely and
|
||||
// keep their neighbours). Bookings it passes make room when they can, as on a desktop
|
||||
// drop (#2983); a timed stop or note in the way, or a move that leaves the day as it
|
||||
// was, is refused.
|
||||
if (itemHasTime(item, day.id) && breaksChronology(newOrder, day.id, getDisplayTimeForDay)) {
|
||||
toast.info(t('dayplan.cannotBreakChronology'))
|
||||
const yielded = yieldTimedBookings(newOrder, toIndex, day.id, getDisplayTimeForDay)
|
||||
if (!yielded || yielded.every((it, i) => it === merged[i])) {
|
||||
toast.info(t('dayplan.cannotBreakChronology'))
|
||||
return
|
||||
}
|
||||
void applyMergedOrder(day.id, yielded)
|
||||
return
|
||||
}
|
||||
void applyMergedOrder(day.id, newOrder)
|
||||
|
||||
@@ -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} />
|
||||
<TourGpxPreviewDialog gpxImport={fileImport.tourImport} offerPoints />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -299,6 +299,19 @@ 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
|
||||
@@ -330,7 +343,7 @@ function PollFormSheet({ open, onClose, onSubmit, t }: {
|
||||
question: question.trim(),
|
||||
options: trimmedOptions,
|
||||
multipleChoice,
|
||||
deadline: deadline ? new Date(deadline).toISOString() : null,
|
||||
deadline: deadline ? deadlineInstant(deadline) : null,
|
||||
})
|
||||
onClose()
|
||||
} catch {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { avatarSrc } from '../../../../utils/avatarSrc'
|
||||
import type { PackingItem, TripMember } from '../../../../types'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { isPackingPlaceholder } from '../../../../components/Packing/packingListModel'
|
||||
import { packingBringerNames } from '../../../../components/Packing/packingBringers'
|
||||
|
||||
export interface MPackItemSheetProps {
|
||||
planner: TripPlanner
|
||||
@@ -160,7 +161,7 @@ export default function MPackItemSheet({
|
||||
<div className="rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
|
||||
{item.owner_username && (
|
||||
<div className="mb-[9px] font-geist text-[0.6875rem] text-m-muted">
|
||||
{t('packing.broughtBy', { name: item.owner_username })}
|
||||
{t('packing.broughtBy', { name: packingBringerNames(item) })}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -28,6 +28,7 @@ import MPackItemSheet from './MPackItemSheet'
|
||||
import MPackingImportSheet from './MPackingImportSheet'
|
||||
import PackingPrintPreview from '../../../../components/Packing/PackingPrintPreview'
|
||||
import { usePackingExport } from '../../../../components/Packing/usePackingExport'
|
||||
import { packingBringerNames } from '../../../../components/Packing/packingBringers'
|
||||
|
||||
type ActionView = 'menu' | 'apply' | 'save'
|
||||
|
||||
@@ -718,7 +719,7 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
|
||||
</span>
|
||||
)}
|
||||
{!badgeSharedToMe && !badgeSharedByMe && badgeBroughtBy && (
|
||||
<span className="flex flex-none items-center gap-[3px]" title={item.owner_username || undefined}>
|
||||
<span className="flex flex-none items-center gap-[3px]" title={t('packing.broughtBy', { name: packingBringerNames(item) })} aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}>
|
||||
{ownerAvatarUrl
|
||||
? <img src={ownerAvatarUrl} alt={item.owner_username || ''} className="h-5 w-5 flex-none rounded-full object-cover" />
|
||||
: <span className="flex h-5 w-5 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] font-geist text-[0.5625rem] font-bold text-m-muted">{(item.owner_username || '?')[0]?.toUpperCase()}</span>}
|
||||
|
||||
@@ -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">
|
||||
<Tags size={14} /> {t('collections.labels.assign')}
|
||||
<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>
|
||||
)}
|
||||
{c.canDelete && (
|
||||
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<FolderInput size={14} /> {t('collections.moveToList')}
|
||||
<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>
|
||||
)}
|
||||
<button type="button" onClick={() => c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<CopyPlus size={14} /> {t('collections.duplicateToList')}
|
||||
<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>
|
||||
<button type="button" onClick={c.openCopyForSelection} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
|
||||
<Copy size={14} /> {t('collections.copyToTrip')}
|
||||
<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>
|
||||
{c.canDelete && (
|
||||
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} className="col-selbar-btn danger">
|
||||
<Trash2 size={14} /> {t('common.delete')}
|
||||
<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>
|
||||
)}
|
||||
<button type="button" onClick={() => c.setSelectMode(false)} className="col-selbar-btn" aria-label={t('common.cancel')}>
|
||||
|
||||
@@ -133,6 +133,7 @@ export default function JourneyPublicPage() {
|
||||
sortedDates,
|
||||
displayDates,
|
||||
newestFirst,
|
||||
openAtEntryId,
|
||||
setNewestFirst,
|
||||
sidebarMapItems,
|
||||
allPhotos,
|
||||
@@ -695,6 +696,7 @@ export default function JourneyPublicPage() {
|
||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||
<MobileMapTimeline
|
||||
entries={timelineEntries}
|
||||
initialEntryId={openAtEntryId}
|
||||
mapEntries={sidebarMapItems as any}
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
readOnly
|
||||
|
||||
@@ -390,12 +390,11 @@ 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. Share place details stays the admin's call and has a tab of
|
||||
its own there (AdminPage). */}
|
||||
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). */}
|
||||
{!managed && (
|
||||
<SettingsCard icon={Compass} title={t('admin.servicesCard.title')} hint={t('admin.servicesCard.hint')}>
|
||||
<TrekApiCard t={t} managed={managed} />
|
||||
{!managed && (<>
|
||||
<TrekApiCard t={t} />
|
||||
{/* 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
|
||||
@@ -448,10 +447,9 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
<Callout>{t('admin.transitProvider.personalKeyWarning')}</Callout>
|
||||
)}
|
||||
</ProviderBlock>
|
||||
</>)}
|
||||
{/* 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). */}
|
||||
{/* 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. */}
|
||||
<ProviderBlock title={t('admin.placesProvider.title')}>
|
||||
<SettingsHint>{t('admin.placesProvider.subtitle')}</SettingsHint>
|
||||
<div className={savingPlacesProvider ? 'opacity-60' : undefined}>
|
||||
@@ -504,9 +502,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;
|
||||
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
|
||||
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
|
||||
because they are set once and then never touched. */}
|
||||
{!managed && (
|
||||
<SettingsCard icon={KeyRound} title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')}>
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { JourneyMapAutoHandle as JourneyMapHandle } from '../../components/
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { journeyOpenAtEntryId } from '../../components/Journey/journeyOpenAt'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import { createDraftJourneyEntry, photoUrl } from './JourneyDetailPage.helpers'
|
||||
import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
|
||||
@@ -475,34 +477,11 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
|
||||
})
|
||||
}, [current?.gallery])
|
||||
|
||||
/**
|
||||
* Where a journey should open: on today, when today is part of it.
|
||||
*
|
||||
* Every load used to start at the first entry, so keeping a journal on a trip
|
||||
* meant scrolling past everything already written before you could add to it
|
||||
* (discussion #2299). "Today" means the last day at or before now — a rest day
|
||||
* with nothing written still lands you at yesterday rather than at the start.
|
||||
*
|
||||
* Only while the journey is running, though. A journey that ended last spring
|
||||
* is something you read, and reading starts at the beginning; opening it at the
|
||||
* last page would be the worse of the two mistakes.
|
||||
*/
|
||||
/** Where a journey should open: a linked entry, else today while it runs (see journeyOpenAtEntryId). */
|
||||
const openAtEntryId = useMemo(() => {
|
||||
const entries = current?.entries ?? []
|
||||
if (entries.length === 0) return null
|
||||
if (linkedEntryId !== null && entries.some(e => String(e.id) === linkedEntryId)) return linkedEntryId
|
||||
const dated = [...entries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
|
||||
const today = new Date()
|
||||
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
const first = dated[0]!.entry_date
|
||||
const last = dated[dated.length - 1]!.entry_date
|
||||
if (todayKey < first || todayKey > last) return null
|
||||
const reached = dated.filter(e => e.entry_date <= todayKey)
|
||||
const target = reached[reached.length - 1] ?? dated[0]!
|
||||
// The first entry OF that day, not the last one before now: a day is read
|
||||
// from its beginning.
|
||||
const dayStart = dated.find(e => e.entry_date === target.entry_date) ?? target
|
||||
return String(dayStart.id)
|
||||
return journeyOpenAtEntryId(entries, { today: localIsoDate() })
|
||||
}, [current?.entries, linkedEntryId])
|
||||
|
||||
// Desktop only: scroll it into the feed once per journey and once per link, after
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-PUBHOOK-001 to FE-JRN-PUBHOOK-002
|
||||
// FE-JRN-PUBHOOK-001 to FE-JRN-PUBHOOK-003
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -32,14 +32,14 @@ function buildEntry(over: Partial<PublicEntry> & { id: number }): PublicEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function serveShare(entries: PublicEntry[]): void {
|
||||
function serveShare(entries: PublicEntry[], permissions: Record<string, boolean> = {}): void {
|
||||
server.use(
|
||||
http.get('/api/public/journey/tok-1', () =>
|
||||
HttpResponse.json({
|
||||
journey: { title: 'Japan' },
|
||||
entries,
|
||||
gallery: [],
|
||||
permissions: { share_timeline: true, share_gallery: true, share_map: false },
|
||||
permissions: { share_timeline: true, share_gallery: true, share_map: false, ...permissions },
|
||||
stats: {},
|
||||
})
|
||||
)
|
||||
@@ -91,4 +91,21 @@ describe('useJourneyPublic', () => {
|
||||
]);
|
||||
expect(result.current.stopNumberById.has('7')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-JRN-PUBHOOK-003: a link published newest-first opens on the first entry of the latest day', async () => {
|
||||
serveShare(
|
||||
[
|
||||
buildEntry({ id: 1, entry_date: '2020-03-01' }),
|
||||
buildEntry({ id: 2, entry_date: '2020-03-02' }),
|
||||
buildEntry({ id: 3, entry_date: '2020-03-02', sort_order: 1 }),
|
||||
],
|
||||
{ newest_first: true }
|
||||
);
|
||||
const { result } = renderHook(() => useJourneyPublic());
|
||||
|
||||
await waitFor(() => expect(result.current.openAtEntryId).toBe('2'));
|
||||
result.current.setNewestFirst(false);
|
||||
// A journey that ended long ago, read oldest-first, starts at the beginning.
|
||||
await waitFor(() => expect(result.current.openAtEntryId).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import { journeyMapStops } from '../../components/Journey/journeyMapStops'
|
||||
import { posterlessVideo } from '../journeyDetail/JourneyDetailPage.helpers'
|
||||
import { groupByDate, type PublicEntry, type PublicGalleryPhoto } from './journeyPublicModel'
|
||||
import { useJourneyMapLayers } from '../../components/Journey/useJourneyMapLayers'
|
||||
import { journeyOpenAtEntryId } from '../../components/Journey/journeyOpenAt'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
|
||||
/**
|
||||
* Public-journey (read-only share) data hook — owns the token fetch, the
|
||||
@@ -144,6 +146,12 @@ export function useJourneyPublic() {
|
||||
() => (effectiveNewestFirst ? [...sortedDates].reverse() : sortedDates),
|
||||
[sortedDates, effectiveNewestFirst],
|
||||
)
|
||||
// The phone's map carousel keeps the trip in time order, so newest-first there means
|
||||
// opening on the latest day instead of reversing the cards (#2851).
|
||||
const openAtEntryId = useMemo(
|
||||
() => journeyOpenAtEntryId(timelineEntries, { today: localIsoDate(), newestFirst: effectiveNewestFirst }),
|
||||
[timelineEntries, effectiveNewestFirst],
|
||||
)
|
||||
|
||||
// Two-column desktop layout: timeline feed left + sticky map right
|
||||
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
|
||||
@@ -166,7 +174,7 @@ export function useJourneyPublic() {
|
||||
perms, journey, stats,
|
||||
timelineEntries, groupedEntries, sortedDates, displayDates, sidebarMapItems, allPhotos, stopNumberById, mapPhotos,
|
||||
mapLayers, entryTitleOf,
|
||||
newestFirst: effectiveNewestFirst, setNewestFirst,
|
||||
newestFirst: effectiveNewestFirst, setNewestFirst, openAtEntryId,
|
||||
desktopTwoColumn,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,13 @@
|
||||
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); }
|
||||
@@ -254,6 +261,8 @@
|
||||
|
||||
/* ----------------- 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);
|
||||
@@ -272,8 +281,40 @@
|
||||
/* 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
|
||||
@@ -318,7 +359,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: min(52ch, 100%); flex-wrap: nowrap; overflow-x: auto;
|
||||
pointer-events: auto; max-width: 100%; flex-wrap: nowrap; overflow: hidden;
|
||||
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);
|
||||
@@ -326,6 +367,17 @@
|
||||
}
|
||||
.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); }
|
||||
@@ -358,7 +410,21 @@
|
||||
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; }
|
||||
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
/* 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; }
|
||||
}
|
||||
/* 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; }
|
||||
@@ -583,12 +649,13 @@
|
||||
.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-end > [role="button"] { position: relative; }
|
||||
.trek-dash .col-lrow-end > [role="button"]::after { content: ''; position: absolute; inset: -9px -6px; }
|
||||
.trek-dash .col-lrow-status > [role="button"] { position: relative; }
|
||||
.trek-dash .col-lrow-status > [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,10 +1,9 @@
|
||||
// FE-UTIL-DATEFMT-001 to FE-UTIL-DATEFMT-020
|
||||
// FE-UTIL-DATEFMT-001 to FE-UTIL-DATEFMT-023
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
dateFieldOrder,
|
||||
dateFormatOptions,
|
||||
dateLayout,
|
||||
displayDateFormatter,
|
||||
formatDisplayDate,
|
||||
formatDisplayDateTime,
|
||||
isNumericDate,
|
||||
@@ -78,13 +77,27 @@ 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: 'medium', timeZone: 'UTC' },
|
||||
{ dateStyle: 'long', timeZone: 'UTC' },
|
||||
{ dateStyle: 'full', 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',
|
||||
@@ -157,30 +170,12 @@ describe('dateFormat (#2971)', () => {
|
||||
};
|
||||
expect(formatDisplayDateTime(NYE, 'en-US', named, 'YYYY-MM-DD')).toBe(NYE.toLocaleString('en-US', named));
|
||||
});
|
||||
});
|
||||
|
||||
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)
|
||||
);
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,6 +201,10 @@ 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,20 +35,53 @@ 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 name the 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.
|
||||
*/
|
||||
export function isNumericDate(opts: Intl.DateTimeFormatOptions = {}): boolean {
|
||||
export function isNumericDate(opts: Intl.DateTimeFormatOptions = {}, locale?: string): boolean {
|
||||
if (opts.weekday || opts.era) return false;
|
||||
if (opts.dateStyle) return opts.dateStyle === 'short';
|
||||
if (opts.dateStyle) return opts.dateStyle === 'short' || isNumericStyle(opts.dateStyle, locale);
|
||||
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);
|
||||
|
||||
@@ -98,7 +131,7 @@ export function formatDisplayDate(
|
||||
const weekday = date.toLocaleDateString(locale, { weekday: opts.weekday, timeZone: opts.timeZone });
|
||||
return `${weekday}, ${numericDate(date, opts, layout)}`;
|
||||
}
|
||||
if (!isNumericDate(opts)) return date.toLocaleDateString(locale, opts);
|
||||
if (!isNumericDate(opts, locale)) return date.toLocaleDateString(locale, opts);
|
||||
return numericDate(date, opts, layout);
|
||||
}
|
||||
|
||||
@@ -122,7 +155,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) || Number.isNaN(date.getTime())) return date.toLocaleString(locale, opts);
|
||||
if (!layout || !isNumericDate(opts, locale) || 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;
|
||||
@@ -130,28 +163,6 @@ 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);
|
||||
@@ -162,9 +173,6 @@ 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, pinnedAway, stopDayIds, getPlaceBookings, getMergedItems, chronoKeyOf, nightRowsOf, rideSeatKey, seatTimedDrop, stayStartingOn, storedRideSlot, timedSlot, type MergedItem } from './dayMerge'
|
||||
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, pinnedAway, stopDayIds, getPlaceBookings, getMergedItems, chronoKeyOf, nightRowsOf, rideSeatKey, seatTimedDrop, yieldTimedBookings, stayStartingOn, storedRideSlot, timedSlot, type MergedItem } from './dayMerge'
|
||||
import { planMergedOrder, withTransportPositions } from './mergedOrder'
|
||||
|
||||
describe('parseTimeToMinutes', () => {
|
||||
@@ -646,6 +646,52 @@ describe('seatTimedDrop', () => {
|
||||
const order = draw(setup())
|
||||
expect(seatTimedDrop(order, 1, DAY)).toEqual(order)
|
||||
})
|
||||
|
||||
// #2983: a flight listed below the day's untimed stops held every slot among them.
|
||||
const untimedDay = (rides: ReturnType<typeof ride>[]): Day => ({
|
||||
stops: [stop(1, 'Hotel'), stop(2, 'Shrine'), stop(3, 'Crossing'), stop(4, 'Tower')].map((a, i) => ({ ...a, order_index: i })),
|
||||
rides,
|
||||
notes: [],
|
||||
})
|
||||
|
||||
it('a ride dropped among untimed stops stays there, and an earlier booking below them makes room ahead of it', () => {
|
||||
const day = untimedDay([ride(101, '06:30', 3.5), ride(102, '13:00', 3.6)])
|
||||
expect(draw(day).map(label)).toEqual(['Hotel', 'Shrine', 'Crossing', 'Tower', 'ride 06:30', 'ride 13:00'])
|
||||
const { seated, drawn } = drop(day, isRide(102), isStop('Shrine'))
|
||||
expect(drawn).toEqual(seated)
|
||||
expect(drawn).toEqual(['Hotel', 'Shrine', 'ride 06:30', 'ride 13:00', 'Crossing', 'Tower'])
|
||||
})
|
||||
|
||||
it('a later booking ahead of the drop moves behind it, and the stops keep their order', () => {
|
||||
const day = untimedDay([ride(101, '09:00', -0.5), ride(102, '18:00', 1.5)])
|
||||
expect(draw(day).map(label)).toEqual(['ride 09:00', 'Hotel', 'Shrine', 'ride 18:00', 'Crossing', 'Tower'])
|
||||
const { seated, drawn } = drop(day, isRide(101), isStop('Tower'))
|
||||
expect(drawn).toEqual(seated)
|
||||
expect(drawn).toEqual(['Hotel', 'Shrine', 'Crossing', 'Tower', 'ride 09:00', 'ride 18:00'])
|
||||
})
|
||||
|
||||
it('a timed stop in the way still sends the ride back to its time, the bookings stay put', () => {
|
||||
const day = untimedDay([ride(101, '06:30', 3.5), ride(102, '13:00', 3.6)])
|
||||
day.stops[2] = { ...day.stops[2], place: { name: 'Crossing', place_time: '12:00' } }
|
||||
expect(draw(day).map(label)).toEqual(['Hotel', 'Shrine', 'ride 06:30', 'Crossing', 'Tower', 'ride 13:00'])
|
||||
const { seated, drawn } = drop(day, isRide(102), isStop('Shrine'))
|
||||
expect(drawn).toEqual(seated)
|
||||
expect(drawn).toEqual(['Hotel', 'Shrine', 'ride 06:30', 'Crossing', 'ride 13:00', 'Tower'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('yieldTimedBookings', () => {
|
||||
const DAY = 1
|
||||
const row = (type: MergedItem['type'], data: Record<string, unknown>): MergedItem => ({ type, sortKey: 0, data })
|
||||
const place = (id: number) => row('place', { id, place: { place_time: null } })
|
||||
const booking = (id: number, time: string | null) => row('transport', { id, day_id: DAY, reservation_time: time })
|
||||
|
||||
it('leaves an untimed row, a stop and an order without a booking out of place to the caller', () => {
|
||||
const order = [place(1), booking(2, null), booking(3, '2026-11-20T08:00')]
|
||||
expect(yieldTimedBookings(order, 1, DAY)).toBeNull()
|
||||
expect(yieldTimedBookings(order, 0, DAY)).toBeNull()
|
||||
expect(yieldTimedBookings(order, 2, DAY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -670,14 +716,63 @@ describe('getMergedItems: bookings in different time zones (#2929)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the stops of the day where their clock puts them', () => {
|
||||
it('keeps a stop with no location, and a note, 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 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'])
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the zone from the endpoints when the metadata names none', () => {
|
||||
|
||||
+118
-23
@@ -4,6 +4,7 @@ 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'])
|
||||
@@ -361,9 +362,10 @@ function applyChronoOrder(
|
||||
|
||||
/**
|
||||
* The minutes the day orders an item by: its own clock, or for a booking in a known
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function chronoKeyOf(
|
||||
items: readonly MergedItem[],
|
||||
@@ -399,13 +401,22 @@ function transportTimeZone(r: MergedItem['data'], dayId: number): string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
function zonedClockSlots(
|
||||
items: MergedItem[],
|
||||
@@ -413,12 +424,14 @@ function zonedClockSlots(
|
||||
getDisplayTime: DisplayTimeOf,
|
||||
clockOf: (it: MergedItem) => number | null
|
||||
): Map<MergedItem, number> {
|
||||
const zoned = items.flatMap((it, index) => {
|
||||
const bookings = items.flatMap((it, index) => {
|
||||
if (it.type !== 'transport') return []
|
||||
const clock = clockOf(it)
|
||||
const instant = zonedInstant(getDisplayTime(it.data, dayId), transportTimeZone(it.data, dayId))
|
||||
return clock === null || instant === null ? [] : [{ it, index, clock, instant }]
|
||||
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 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)
|
||||
@@ -428,20 +441,100 @@ function zonedClockSlots(
|
||||
return slots
|
||||
}
|
||||
|
||||
/** Whether the list draws `order` as it stands. */
|
||||
function drawnAsIs(order: readonly MergedItem[], dayId: number, getDisplayTime: DisplayTimeOf): boolean {
|
||||
return applyChronoOrder([...order], dayId, getDisplayTime).every((it, i) => it === order[i])
|
||||
}
|
||||
|
||||
/**
|
||||
* A day order with the row at `index` kept where it is, and the other timed bookings it
|
||||
* puts out of time order moved next to it: the ones due earlier right ahead of it, the
|
||||
* later ones right behind it, each group in the order it had. Stops and notes never move.
|
||||
* Null when the row has no time, when no booking is out of order, or when the result is
|
||||
* still not the order the list draws, which is when a timed stop or note stands against it.
|
||||
*
|
||||
* A booking nobody can drag otherwise blocks every slot on its far side: with a 06:30
|
||||
* flight listed below the day's untimed stops, a 13:00 transit dropped between two of
|
||||
* them went back behind the flight on every drop (#2983).
|
||||
*/
|
||||
export function yieldTimedBookings(
|
||||
order: readonly MergedItem[],
|
||||
index: number,
|
||||
dayId: number,
|
||||
getDisplayTime: DisplayTimeOf = getDisplayTimeForDay
|
||||
): MergedItem[] | null {
|
||||
const moved = order[index]
|
||||
if (!moved || moved.type === 'place') return null
|
||||
const keyOf = chronoKeyOf(order, dayId, getDisplayTime)
|
||||
const own = keyOf(moved)
|
||||
if (own === null) return null
|
||||
const ahead: MergedItem[] = []
|
||||
const behind: MergedItem[] = []
|
||||
order.forEach((it, i) => {
|
||||
if (it === moved || it.type !== 'transport') return
|
||||
const key = keyOf(it)
|
||||
if (key === null) return
|
||||
if (i > index && key < own) ahead.push(it)
|
||||
else if (i < index && key > own) behind.push(it)
|
||||
})
|
||||
if (!ahead.length && !behind.length) return null
|
||||
const yielding = new Set([...ahead, ...behind])
|
||||
const kept = order.filter(it => !yielding.has(it))
|
||||
const at = kept.indexOf(moved)
|
||||
const result = [...kept.slice(0, at), ...ahead, moved, ...behind, ...kept.slice(at + 1)]
|
||||
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
|
||||
* take that time too. Dropped against its time, a train went back to its time on the
|
||||
* next render and took every untimed stop behind the drop with it, and the next drag
|
||||
* stored that order (#2631). So it lands as close to the drop as its time allows and
|
||||
* nothing else moves. A row without a time, or one the list draws where it was
|
||||
* dropped, is returned as it came.
|
||||
* stored that order (#2631). So it stays where it was dropped when only other bookings
|
||||
* stand against its time, and they make room (`yieldTimedBookings`); against a timed
|
||||
* stop or note it lands as close to the drop as its time allows and nothing else moves.
|
||||
* A row without a time, or one the list draws where it was dropped, is returned as it came.
|
||||
*/
|
||||
export function seatTimedDrop(
|
||||
order: readonly MergedItem[],
|
||||
index: number,
|
||||
dayId: number,
|
||||
getDisplayTime: (r: MergedItem['data'], dayId: number) => string | null = getDisplayTimeForDay
|
||||
getDisplayTime: DisplayTimeOf = getDisplayTimeForDay
|
||||
): MergedItem[] {
|
||||
const moved = order[index]
|
||||
if (!moved || moved.type === 'place') return [...order]
|
||||
@@ -449,16 +542,18 @@ export function seatTimedDrop(
|
||||
? parseTimeToMinutes(moved.data?.time)
|
||||
: parseTimeToMinutes(getDisplayTime(moved.data, dayId))
|
||||
if (minutes === null) return [...order]
|
||||
if (drawnAsIs(order, dayId, getDisplayTime)) return [...order]
|
||||
const yielded = yieldTimedBookings(order, index, dayId, getDisplayTime)
|
||||
if (yielded) return yielded
|
||||
const rest = order.filter((_, i) => i !== index)
|
||||
const drawnAsIs = (at: number): MergedItem[] | null => {
|
||||
const seatAt = (at: number): MergedItem[] | null => {
|
||||
const candidate = [...rest.slice(0, at), moved, ...rest.slice(at)]
|
||||
const drawn = applyChronoOrder(candidate, dayId, getDisplayTime)
|
||||
return drawn.every((it, i) => it === candidate[i]) ? candidate : null
|
||||
return drawnAsIs(candidate, dayId, getDisplayTime) ? candidate : null
|
||||
}
|
||||
for (let dist = 0; dist <= rest.length; dist++) {
|
||||
for (const at of dist === 0 ? [index] : [index - dist, index + dist]) {
|
||||
for (let dist = 1; dist <= rest.length; dist++) {
|
||||
for (const at of [index - dist, index + dist]) {
|
||||
if (at < 0 || at > rest.length) continue
|
||||
const seated = drawnAsIs(at)
|
||||
const seated = seatAt(at)
|
||||
if (seated) return seated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { frameLine, frameLng, tripLngFrame } from './lngFrame';
|
||||
import { frameLine, frameLng, tripLngFrame, viewLngFrame } from './lngFrame';
|
||||
|
||||
const LA = -118.4;
|
||||
const AUCKLAND = 174.8;
|
||||
@@ -83,3 +83,32 @@ 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,3 +106,19 @@ 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-024
|
||||
// FE-MOB-POLLS-001 to FE-MOB-POLLS-030
|
||||
|
||||
const ME = 7
|
||||
|
||||
@@ -353,6 +353,25 @@ 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([])
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { act, renderHook, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-PLTL-001 to FE-MOB-PLTL-052
|
||||
// FE-MOB-PLTL-001 to FE-MOB-PLTL-054
|
||||
|
||||
// The connector calculation is its own hook with real OSRM calls — stubbed here so
|
||||
// the timeline sees exactly the legs a test wants to match against.
|
||||
@@ -337,6 +337,41 @@ describe('useMPlanTimeline', () => {
|
||||
expect(planner.toast.info).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// #2983: a 06:30 flight listed below the day's untimed stops blocked every slot of a
|
||||
// 13:00 transit among them. The flight makes room ahead of the transit instead.
|
||||
const FLIGHT_EARLY = buildReservation({
|
||||
id: 61, type: 'flight', title: 'Flight', day_id: 2, reservation_time: '2026-05-02T06:30', day_positions: { 2: 1.5 },
|
||||
})
|
||||
const TRANSIT_NOON = buildReservation({
|
||||
id: 62, type: 'transit', title: 'Transit', day_id: 2, reservation_time: '2026-05-02T13:00', day_positions: { 2: 1.6 },
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-053: keeps a timed booking where it was moved and lets an earlier booking make room', async () => {
|
||||
seedStore(useTripStore, { reservations: [FLIGHT_EARLY, TRANSIT_NOON] })
|
||||
const planner = makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] }, reservations: [FLIGHT_EARLY, TRANSIT_NOON] })
|
||||
const { result } = await renderTimeline(planner)
|
||||
expect(result.current.merged.map(m => m.data.id)).toEqual([11, 12, 61, 62])
|
||||
|
||||
await act(async () => { result.current.moveRowTo(result.current.merged[3], 1) })
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.updateReservationPositions).toHaveBeenCalledWith(
|
||||
1, [{ id: 61, day_plan_position: 1 / 3 }, { id: 62, day_plan_position: 2 / 3 }], 2,
|
||||
))
|
||||
expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [11, 12])
|
||||
expect(planner.toast.info).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-054: still refuses a move the earlier booking would only undo', async () => {
|
||||
seedStore(useTripStore, { reservations: [FLIGHT_EARLY, TRANSIT_NOON] })
|
||||
const planner = makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] }, reservations: [FLIGHT_EARLY, TRANSIT_NOON] })
|
||||
const { result } = await renderTimeline(planner)
|
||||
|
||||
await act(async () => { result.current.moveRow(result.current.merged[3], 'up') })
|
||||
|
||||
expect(planner.toast.info).toHaveBeenCalledWith('dayplan.cannotBreakChronology')
|
||||
expect(planner.tripActions.updateReservationPositions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-021: persists note and transport positions alongside the reorder', async () => {
|
||||
const bus = buildReservation({
|
||||
id: 51, type: 'bus', title: 'Bus 13A', day_id: 2, day_positions: { 2: 1.5 },
|
||||
|
||||
Generated
+170
-135
@@ -110,7 +110,7 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"autoprefixer": "^10.6.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-config-flat-gitignore": "^2.4.0",
|
||||
@@ -129,9 +129,9 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "8.3.0",
|
||||
"vite": "8.3.5",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^4.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
@@ -11093,9 +11093,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitest/coverage-istanbul": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.11.tgz",
|
||||
"integrity": "sha512-pK9LJtOS534bmUwdwLpGD9qL52DeqVWSInrXFMNqrvWLbbAhuIPAlRup3UbsjFOMqCC/p3MmmX7uXV7ZTkcFfQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.12.tgz",
|
||||
"integrity": "sha512-f98TiCqF7IriBQEpm6usi56wyD4G1cRYabI15f3Jm7R355q9tlwqJ0j1oPf+tWZ7D0LPnCh3dU70duRt10erHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11114,18 +11114,18 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "4.1.11"
|
||||
"vitest": "4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
|
||||
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
|
||||
"integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -11139,8 +11139,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.11",
|
||||
"vitest": "4.1.11"
|
||||
"@vitest/browser": "4.1.12",
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -11149,16 +11149,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -11167,13 +11167,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -11194,9 +11194,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11207,13 +11207,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -11221,14 +11221,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -11237,9 +11237,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -11247,13 +11247,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -18835,9 +18835,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -19755,9 +19755,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"version": "8.5.32",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
|
||||
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -19775,9 +19775,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.18",
|
||||
"nanoid": "^3.3.20",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -23701,16 +23701,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.28",
|
||||
"rolldown": "~1.2.6",
|
||||
"postcss": "^8.5.30",
|
||||
"rolldown": "~1.2.14",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -23727,7 +23727,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.7.1",
|
||||
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -23810,19 +23810,36 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@oxc-project/types": {
|
||||
"version": "0.150.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
|
||||
"integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==",
|
||||
"version": "0.156.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz",
|
||||
"integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==",
|
||||
"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.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==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz",
|
||||
"integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -23837,9 +23854,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -23854,9 +23871,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-darwin-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -23871,9 +23888,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz",
|
||||
"integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -23888,9 +23905,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -23905,13 +23922,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -23922,13 +23942,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -23939,13 +23962,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -23956,13 +23982,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -23973,13 +24002,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -23990,13 +24022,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -24007,9 +24042,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -24024,9 +24059,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -24041,9 +24076,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -24071,13 +24106,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/rolldown": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
|
||||
"integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==",
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz",
|
||||
"integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.150.0",
|
||||
"@oxc-project/types": "=0.156.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -24087,37 +24122,37 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@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"
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -24145,12 +24180,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.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",
|
||||
"@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",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
"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,17 +36,29 @@ 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 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
|
||||
# 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
|
||||
# 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 node \) -print -quit 2>/dev/null)" ]; then
|
||||
if chown node:node "$dir" 2>/dev/null; then
|
||||
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
|
||||
echo "Correcting ownership of $dir for the node user..."
|
||||
chown -R -h node:node "$dir" 2>/dev/null || true
|
||||
chown -R -h node "$dir" 2>/dev/null || true
|
||||
find "$dir" -group 0 -exec chgrp -h node {} + 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": 1613,
|
||||
"src/nest/budget/budget.service.ts": 1607,
|
||||
"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": 2,
|
||||
"tests/unit/nest/notifications/storage-health-notifier.service.test.ts": 1,
|
||||
"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,6 +254,23 @@ 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,6 +52,19 @@ 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
|
||||
@@ -261,4 +274,49 @@ 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,8 +120,13 @@ export class AddonsService {
|
||||
return this.photoProviders.listEnabledIds();
|
||||
}
|
||||
|
||||
/** wanderer may be used: Tours is on and the admin allowed it under Tours. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async isWandererAvailable(): Promise<boolean> {
|
||||
if (readEnv().managed.enabled) return false;
|
||||
return (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursWanderer()).enabled;
|
||||
}
|
||||
|
||||
|
||||
@@ -264,8 +264,14 @@ 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, announcing or wordless answer). */
|
||||
/** Whether the turn used its one push (an empty, cut-off or announcing 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;
|
||||
}
|
||||
@@ -528,7 +534,7 @@ function answeredAsk(call: AiToolCall): AssistantMessagePart {
|
||||
*/
|
||||
async function askForTheAnswer(turn: Turn, rowId: number, step: StepRecord & { asks: AiToolCall[] }): Promise<null> {
|
||||
const { asks, providerState } = step;
|
||||
turn.nudged = true;
|
||||
turn.askedForAnswer = 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');
|
||||
@@ -560,12 +566,15 @@ 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.nudged) return askForTheAnswer(turn, rowId, { parts, providerState, asks });
|
||||
if (empty && asks.length > 0 && !turn.askedForAnswer)
|
||||
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;
|
||||
if (push && !turn.nudged) {
|
||||
// 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) {
|
||||
turn.nudged = true;
|
||||
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
|
||||
w.finishStep();
|
||||
@@ -663,6 +672,7 @@ export async function runAgentLoop(
|
||||
usage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
|
||||
knownPlaceIds,
|
||||
nudged: false,
|
||||
askedForAnswer: false,
|
||||
followUps: null,
|
||||
};
|
||||
return stepsFrom(turn, 0);
|
||||
|
||||
@@ -26,6 +26,19 @@ 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.
|
||||
@@ -48,17 +61,23 @@ 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.
|
||||
* 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.
|
||||
*/
|
||||
function recordedCalls(state: AiProviderState | null): Array<{ id: string; name: string }> {
|
||||
const entries =
|
||||
function recordedCalls(state: AiProviderState | null): AiToolCall[] {
|
||||
const calls =
|
||||
state?.adapter === 'anthropic-messages'
|
||||
? state.blocks.filter((block) => block.type === 'tool_use').map((block) => [block.id, block.name])
|
||||
? state.blocks
|
||||
.filter((block) => block.type === 'tool_use')
|
||||
.map((block) => ({ id: block.id, name: block.name, args: recordOf(block.input) }))
|
||||
: state?.adapter === 'openai-responses'
|
||||
? state.items.filter((item) => item.type === 'function_call').map((item) => [item.call_id, item.name])
|
||||
? state.items
|
||||
.filter((item) => item.type === 'function_call')
|
||||
.map((item) => ({ id: item.call_id, name: item.name, args: recordOf(item.arguments) }))
|
||||
: [];
|
||||
return entries.flatMap(([id, name]) =>
|
||||
typeof id === 'string' ? [{ id, name: typeof name === 'string' ? name : '' }] : [],
|
||||
return calls.flatMap(({ id, name, args }) =>
|
||||
typeof id === 'string' ? [{ id, name: typeof name === 'string' ? name : '', args }] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -314,7 +333,16 @@ 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}`);
|
||||
history.push({ role: 'assistant', text, toolCalls, providerState });
|
||||
// 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 });
|
||||
calls.forEach((part, index) => {
|
||||
history.push({
|
||||
role: 'tool',
|
||||
@@ -324,11 +352,8 @@ export function toChatHistory(
|
||||
...(part.isError ? { 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 });
|
||||
for (const call of unrun) {
|
||||
history.push({ role: 'tool', toolCallId: call.id, name: call.name, output: NOT_RUN, isError: true });
|
||||
}
|
||||
});
|
||||
return history;
|
||||
|
||||
@@ -59,6 +59,7 @@ 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';
|
||||
@@ -266,9 +267,12 @@ export class AssistantToolSession {
|
||||
/** The groups this turn loaded; the core is always there. */
|
||||
private readonly loaded = new Set<string>();
|
||||
|
||||
/** Tools the model called before their group was loaded in this turn, by name, with that group. */
|
||||
/** Reads the model called before their group was loaded in this turn, by name, with that group; told once. */
|
||||
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';
|
||||
@@ -280,6 +284,8 @@ 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))
|
||||
@@ -338,9 +344,27 @@ 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, and a refusal of these arguments says so (see call()).
|
||||
// schema.
|
||||
this.loaded.add(unloaded);
|
||||
this.calledBlind.set(name, 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);
|
||||
}
|
||||
const given = pinNamesOf(this.pinArg).filter((key) => args[key] !== undefined);
|
||||
if (given.some((key) => Number(args[key]) !== this.pinnedId)) {
|
||||
@@ -361,7 +385,10 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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,14 +191,23 @@ export class UserCleanupService {
|
||||
*/
|
||||
async deleteUserCompletely(userId: number): Promise<void> {
|
||||
const avatar = await this.usersRepo.getAvatar(userId);
|
||||
await this.uow.transactional(async () => {
|
||||
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.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 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.
|
||||
// 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.
|
||||
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,5 +236,81 @@ 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, toDisplay, converterFor, settleConverterFor, isDirect };
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { BudgetWritesService } from './budget-writes.service';
|
||||
import { BudgetService } from './budget.service';
|
||||
import { ExchangeRatesService } from './exchange-rates.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { idSchema, sumMinor, toMinor } from '@trek/shared';
|
||||
import { COST_CATEGORY_HINT, idSchema, sumMinor, toMinor } from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -200,7 +200,7 @@ export class BudgetMcp {
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
|
||||
total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'),
|
||||
currency: z
|
||||
.string()
|
||||
@@ -341,7 +341,7 @@ export class BudgetMcp {
|
||||
tripId: idSchema,
|
||||
itemId: idSchema,
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
|
||||
total_price: z.number().optional(),
|
||||
currency: z
|
||||
.string()
|
||||
@@ -482,7 +482,7 @@ export class BudgetMcp {
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
|
||||
total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'),
|
||||
note: z.string().max(500).optional(),
|
||||
userIds: z
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type { BudgetFallbackFx, BudgetParticipantFinal, BudgetUnconverted, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { splitEqualShares, sumMinor, toMinor } from '@trek/shared';
|
||||
import { costCategoryOf, splitEqualShares, storedCostCategory, sumMinor, toMinor } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
@@ -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 { customSplitCents, isFrozenRate, NOT_FROZEN_RATE, settlementFx, summaryShareCents, sumMoney, tripConverter } from './budget-money.helpers';
|
||||
import { applyTransfer, customSplitCents, isFrozenRate, NOT_FROZEN_RATE, settlementFx, summaryShareCents, sumMoney, tripConverter } from './budget-money.helpers';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
@@ -520,7 +520,7 @@ export class BudgetService {
|
||||
const maxOrder = await this.budgetItemsRepo.maxSortOrder(tripId);
|
||||
const sortOrder = (maxOrder !== null ? maxOrder : -1) + 1;
|
||||
|
||||
const cat = data.category || 'other';
|
||||
const cat = storedCostCategory(data.category);
|
||||
|
||||
// Ensure category has a sort_order entry
|
||||
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, cat);
|
||||
@@ -660,7 +660,7 @@ export class BudgetService {
|
||||
// (the legacy `COALESCE(?, col)` truthy-wins shape), everything else
|
||||
// passes `present = <field> !== undefined` (a true presence sentinel).
|
||||
await this.budgetItemsRepo.update(id, {
|
||||
category: [!!data.category, data.category || ''],
|
||||
category: [!!data.category, data.category ? storedCostCategory(data.category) : ''],
|
||||
name: [!!data.name, data.name || ''],
|
||||
total_price: [data.total_price !== undefined, data.total_price !== undefined ? data.total_price : 0],
|
||||
currency: [data.currency !== undefined, data.currency !== undefined ? (data.currency || null) : null],
|
||||
@@ -705,11 +705,11 @@ export class BudgetService {
|
||||
|
||||
// If category changed, update category order table
|
||||
if (data.category) {
|
||||
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, data.category);
|
||||
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, storedCostCategory(data.category));
|
||||
if (!catExists) {
|
||||
const maxCatOrder = await this.budgetCategoryOrderRepo.maxSortOrder(tripId);
|
||||
const catOrder = (maxCatOrder !== null && maxCatOrder !== undefined ? maxCatOrder : -1) + 1;
|
||||
await this.budgetCategoryOrderRepo.insertIgnore(tripId, data.category, catOrder);
|
||||
await this.budgetCategoryOrderRepo.insertIgnore(tripId, storedCostCategory(data.category), catOrder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,7 +1037,7 @@ export class BudgetService {
|
||||
}
|
||||
const cents = toMinor(convert(r.total_price || 0));
|
||||
total += cents;
|
||||
const cat = r.category || '';
|
||||
const cat = costCategoryOf(r.category) ?? (r.category || '');
|
||||
byCategory[cat] = (byCategory[cat] || 0) + cents;
|
||||
}
|
||||
return {
|
||||
@@ -1083,7 +1083,7 @@ export class BudgetService {
|
||||
tripId: string | number,
|
||||
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string; baseRate?: number } = {},
|
||||
) {
|
||||
const { currency, displayFactor, toDisplay, converterFor, settleConverterFor, isDirect } = settlementFx(opts);
|
||||
const { currency, displayFactor, displayUnit, toDisplay, converterFor, settleConverterFor, isDirect } = settlementFx(opts);
|
||||
const unconvertedItems: number[] = [];
|
||||
const unconvertedSettlements: number[] = [];
|
||||
const unconvertedCurrencies = new Set<string>();
|
||||
@@ -1137,6 +1137,9 @@ 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());
|
||||
@@ -1170,7 +1173,7 @@ export class BudgetService {
|
||||
let creditCents = 0;
|
||||
for (const p of payers) {
|
||||
const exact = convert(p.amount) * 100;
|
||||
const paid = Math.round(exact);
|
||||
const paid = Math.round(exact / unit) * unit;
|
||||
const payer = ensure(p.user_id, p);
|
||||
if (inDisplay) {
|
||||
payer.direct += paid;
|
||||
@@ -1195,12 +1198,13 @@ 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, convert, creditCents, sumMinor(payers.map(p => p.amount)))
|
||||
: splitEqualShares(creditCents, members, item.id);
|
||||
? customSplitCents(members, amount => convert(amount) / unit, creditCents / unit, sumMinor(payers.map(p => p.amount)))
|
||||
: splitEqualShares(creditCents / unit, members, item.id);
|
||||
for (const m of members) {
|
||||
const member = ensure(m.user_id, m);
|
||||
if (inDisplay) member.direct -= shares[m.user_id] || 0;
|
||||
if (inDisplay) member.direct -= (shares[m.user_id] || 0) * unit;
|
||||
else member.cents -= shares[m.user_id] || 0;
|
||||
}
|
||||
}
|
||||
@@ -1226,33 +1230,20 @@ 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);
|
||||
// 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;
|
||||
// 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;
|
||||
// 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);
|
||||
const shown = finalUnit(exact) + finalUnit(backExact);
|
||||
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: paidDirect });
|
||||
(movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -shown, direct: -paidDirect });
|
||||
(movedRows[s.to_user_id] ??= []).push({ ...moved, cents: shown, direct: movedDirect });
|
||||
(movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -shown, direct: -movedDirect });
|
||||
}
|
||||
|
||||
// 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.collectionsRepo.deleteById(id); // CASCADE drops members + places + tags
|
||||
await this.release.releaseValues(await this.collectionsRepo.deleteWithImages(id)); // cascades 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,6 +8,7 @@ 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';
|
||||
@@ -84,23 +85,12 @@ export class JourneyDomainService {
|
||||
// (Task 0's confirmation: `canAccessTrip` IS `findAccessible`, not
|
||||
// merely equivalent to it).
|
||||
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
|
||||
// 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.
|
||||
// The journey's photos and the entries they hang on (JG15/JG19/JG87-116).
|
||||
@InjectRepository(JourneyPhotos) private readonly photosRepo: JourneyPhotosRepository,
|
||||
@InjectRepository(JourneyEntryPhotos) private readonly entryPhotosRepo: JourneyEntryPhotosRepository,
|
||||
// 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).
|
||||
// JG44 — `onPlaceUpdated` reads the place it follows (`PlacesRepository.findRaw`).
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
private readonly release: StorageReleaseService, // frees the files of a deleted journey's own photos
|
||||
) {}
|
||||
|
||||
private ts(): number {
|
||||
@@ -368,7 +358,17 @@ 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);
|
||||
await this.journeysRepo.deleteById(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.broadcastJourneyEvent(journeyId, 'journey:deleted', {}, sid, audience);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ export class JourneyMcp {
|
||||
tags: z.array(z.string()).optional(),
|
||||
pros_cons: PROS_CONS.optional().describe('The verdict on the place: what was worth it and what was not'),
|
||||
visibility: ENTRY_VISIBILITY.optional().describe(
|
||||
'Defaults to private; "shared" and "public" expose the entry through the journey share link',
|
||||
'Stored with the entry, defaults to private. It does not decide the share link: every entry that is not a draft shows on the journey share link. Use is_draft to keep an entry to the contributors',
|
||||
),
|
||||
type: ENTRY_TYPE.optional().describe(
|
||||
'Defaults to "entry"; "skeleton" is the stub TREK derives from a trip place and hides behind the hide-skeletons preference',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -44,8 +45,19 @@ 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({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PlaceContribOutbox } from '../../db/entities/PlaceContribOutbox.entity';
|
||||
import type { PlacesContributeBackfill } from '../../db/json-columns';
|
||||
import type { PlaceContribOutboxRepository, PlaceTypedRow } from '../../db/repositories/PlaceContribOutbox.repository';
|
||||
import { getCountryFromCoords } from '../../utils/country-borders';
|
||||
import { logInfo } from '../audit/audit-log.logger';
|
||||
@@ -11,22 +12,13 @@ import { contribKeyOf } from './place-contrib.helpers';
|
||||
import type { ContribWrite } from './place-contrib.types';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { PlaceContribField } from '@trek/shared';
|
||||
import { PLACE_CONTRIB_COLUMN, type PlaceContribField } from '@trek/shared';
|
||||
|
||||
/** Places read per step of the catch-up. */
|
||||
const BACKFILL_PAGE = 100;
|
||||
/** The catch-up only adds to the queue while it holds fewer values than this, so new writes never wait behind it. */
|
||||
export const BACKFILL_LOW_WATER = 200;
|
||||
|
||||
const COLUMN = {
|
||||
website: 'website',
|
||||
phone: 'phone',
|
||||
email: 'email',
|
||||
opening_hours: 'opening_hours',
|
||||
description: 'description',
|
||||
image: 'image_url',
|
||||
} as const satisfies Record<PlaceContribField, keyof PlaceTypedRow['place']>;
|
||||
|
||||
/**
|
||||
* The catch-up after an agreement: everything users typed into places since
|
||||
* the origin tracking began (TREK 5.1.0) goes out as well, not only what they
|
||||
@@ -70,22 +62,39 @@ export class PlaceContribBackfill {
|
||||
logInfo('Places contribution: the place details typed before the agreement are all queued');
|
||||
return 0;
|
||||
}
|
||||
const writes = page.map((row) => typedWrite(row, fields)).filter((write) => write.values.length > 0);
|
||||
await this.uow.transactional(async () => {
|
||||
await inOrder(writes, (write) => this.queue.put(write, fields, now, { keepQueued: true }));
|
||||
await this.store.writeBackfill({ fields, afterId: page[page.length - 1].place.id });
|
||||
// The reads above ran outside the transaction, so a withdrawal, a switch
|
||||
// off or a field change may have committed meanwhile. Only a catch-up that
|
||||
// still stands where it was read goes on, and only for fields still on:
|
||||
// what was just taken back must not be queued again.
|
||||
return this.uow.transactional(async () => {
|
||||
if (!sameBackfill(await this.store.backfill(), pending)) return 0;
|
||||
const stillActive = await this.store.activeFields();
|
||||
const live = fields.filter((field) => stillActive.includes(field));
|
||||
if (live.length === 0) return 0;
|
||||
const writes = page.map((row) => typedWrite(row, live)).filter((write) => write.values.length > 0);
|
||||
await inOrder(writes, (write) => this.queue.put(write, live, now, { keepQueued: true }));
|
||||
await this.store.writeBackfill({ fields: live, afterId: page[page.length - 1].place.id });
|
||||
return writes.length;
|
||||
});
|
||||
return writes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function sameBackfill(current: PlacesContributeBackfill | null, read: PlacesContributeBackfill): boolean {
|
||||
return (
|
||||
current !== null &&
|
||||
current.afterId === read.afterId &&
|
||||
current.fields.length === read.fields.length &&
|
||||
current.fields.every((field, i) => field === read.fields[i])
|
||||
);
|
||||
}
|
||||
|
||||
/** The values of one place that are still the ones a user typed, for a place the service can key. */
|
||||
function typedWrite(row: PlaceTypedRow, fields: readonly PlaceContribField[]): ContribWrite {
|
||||
const { place } = row;
|
||||
const keyed = contribKeyOf(place.osm_id) !== null;
|
||||
const values = row.typed.flatMap(({ field, value_hash }) => {
|
||||
const known = fields.find((candidate) => candidate === field);
|
||||
const value = known ? place[COLUMN[known]] : null;
|
||||
const value = known ? place[PLACE_CONTRIB_COLUMN[known]] : null;
|
||||
if (!keyed || !known || typeof value !== 'string' || value.trim() === '') return [];
|
||||
return valueHash(value) === value_hash ? [{ field: known, value }] : [];
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
PLACE_CONTRIB_COLUMN,
|
||||
PLACE_CONTRIB_TERMS_VERSION,
|
||||
type PlaceContribField,
|
||||
type PlaceContribImageAnswer,
|
||||
@@ -336,7 +337,7 @@ function staleReason(
|
||||
|
||||
/** The column a field is stored in. */
|
||||
function columnOf(row: OutboxCurrentRow, field: PlaceContribField): string | null {
|
||||
return field === 'image' ? row.image_url : row[field];
|
||||
return row[PLACE_CONTRIB_COLUMN[field]];
|
||||
}
|
||||
|
||||
/** What the index serves for a field, in the form a queued value has; null when it serves nothing. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user