Compare commits

...

34 Commits

Author SHA1 Message Date
Maurice dbcfbd875e chore(ci): lower the knip export baseline the planner's zone fix brought down 2026-11-05 15:33:35 +01:00
Maurice d2002cc913 fix: keep the audit follow-ups inside the domain boundaries
The field-to-column map of shared place details moves to @trek/shared,
where places and place-contrib both read it without depending on each
other, and the assistant reads a recorded call's arguments with a helper
of its own instead of reaching into the AI providers. The catch-up's
country from the address is taken back out: it made place-contrib depend
on atlas, which already depends on it, and it waits for a shared country
lookup both can use.
2026-11-05 15:18:56 +01:00
Maurice 4bc764eb6a test: hold the audit follow-ups to the test, lint, theme and size ratchets
The packing row tests read the TREK tooltip and the accessible name instead
of the title the badges no longer carry, the journey delete suite takes its
service from the module over a snapshot db and seeds through the row
factories, the storage notifier suite builds its subject once, the POI
layer follows the view only while it has POIs, the collections repository
reads the list's places through its own query, and the baselines the moved
code left smaller come down with it.
2026-11-05 15:05:59 +01:00
Maurice 1f89a13f92 fix(assistant): send a blind change back only when its arguments do not fit its schema
Sending back every change called before its group was loaded cost a step
even when the model had the arguments right, and a valid change was no
longer held as the card the user decides on. A blind change is now held as
always when its arguments are of the shape the schema asks for, and sent
back with the schema loaded only when one is missing or of the wrong kind,
such as a number or a list written as text (#2967).
2026-11-05 15:05:58 +01:00
Maurice 93a23f0343 refactor: keep the audit follow-ups within the size ratchets
Eight files had grown past the line counts their ratchets hold, so code
moves to where it belongs instead of the baselines going up:

- a settle-up transfer is booked by applyTransfer in the money helpers
- whether an expense still carries its derived category is
  isDerivedCostCategory in @trek/shared, next to the category map
- a list is deleted with the images it named by deleteWithImages
- the explore POIs and their world-copy tracking are PoiMarkers
- the journey delete tests get a file of their own, and two stale
  migration-era comments in the journey service shrink to one line each
2026-11-05 14:36:25 +01:00
Maurice ce69ae99a1 chore: keep the audit follow-ups within the lint and format ratchets
The bringers list moves next to the other bringer helpers so the card file
exports components only, three tests read without non-null assertions or
any, and three files take the formatting their checks expect.
2026-11-05 14:36:24 +01:00
Maurice c7d5fc12c6 fix(packing): show who brings a shared item in a card with their pictures, and the others as a +N circle
The row's badges said who brings an item, who it is shared with and who
takes care of it in the browser's own tooltip. They now use TREK's, and
the bringers one lists everyone with their picture on a line of their
own. The others show as a +N circle tucked under the first picture.
Tooltip takes an optional content for tips that hold more than words.
2026-11-05 14:36:24 +01:00
Maurice b9c53d692b fix(map): draw points of interest on the world copy the view is on
POIs fetched for a view west of the trip frame's seam were drawn one world
away, off screen.
2026-11-05 14:36:24 +01:00
Maurice 13376d560f fix(settings): let road trip tiles, medium dates and poll deadlines follow the date format
Road trip day tiles still wrote numeric dates from the clock setting and
dates in the medium style kept the language's numbers. A poll deadline
whose time was cleared was read as UTC midnight.
2026-11-05 14:36:24 +01:00
Maurice 3a8191fc07 fix(costs): keep display-currency payments settled, count them as typed in the plan and the PDF, and file every booking type under its Costs key
A display-currency payment whose halves pointed opposite ways no longer
stayed settled (#1445), the plan sidebar and the PDF total still went
through the trip currency, and a zero-decimal display currency was not
rounded. Every booking type now maps to one Costs key on every path; a
cost the old map filed under 'other' still moves with a type change.
2026-11-05 14:36:23 +01:00
Maurice c0f7f81e23 fix(tours): offer waypoints as places only where a trip takes them, and say when every point was there already
The waypoints switch showed in every Tours import dialog; it now shows in
the trip's import only. Confirming it when every point was already in the
trip closed the dialog without a word.
2026-11-05 14:36:23 +01:00
Maurice 7743e4da16 fix(managed): read the wanderer as off on managed, keep the replica alerts mutable, and drop dead guards 2026-11-05 14:36:23 +01:00
Maurice 887f5d8c84 fix(deps): restore the vite 8.3.5 security floor
Re-applying the reverted dependency update left vite below the patched
version.
2026-11-05 14:36:23 +01:00
Maurice 2ec1199490 fix(place-contrib): re-check consent inside the catch-up step and never share withdrawn history again
The catch-up read its state outside its transaction and could revive a
backfill a withdraw had just ended; switching a field off and on after a
withdrawal shared the withdrawn history again. The catch-up now resolves
the country the way a save does and reads one field map.
2026-11-05 14:36:22 +01:00
Maurice 9dd58fe32f fix(storage): release a deleted journey's photos and a deleted list's images, and keep one bad key from stopping a category
Deleting a journey left its uploaded photos behind for good, and deleting
a list queued neither its cover nor its place images. An object with a key
the store refuses no longer fails every night's pass, and the admin view
keeps the counts of a category skipped for a running backup.
2026-11-05 14:36:22 +01:00
Maurice 402a02a302 fix(docker): say what the volume-root check leaves alone, and skip the walk for a custom fsGroup
The entrypoint comment, the Helm chart README, the wiki and the ssrfGuard
note now describe what the shipped check does.
2026-11-05 14:36:22 +01:00
Maurice 7b5a05afee fix(planner): keep timed stops in their local zone when flights take their clock slots
The zoned flights traded clock times among themselves while stops kept
theirs, so a dinner timed after a flight landed could be drawn before it.
A timed stop near one of the day's zoned endpoints now joins the swap in
that endpoint's zone.
2026-11-05 14:36:22 +01:00
Maurice c3357ebea4 fix(assistant): send a blind write back with its schema, pair unrun calls with their answers, and give the wordless-answer recovery its own nudge
A write from a group the turn had not loaded was held as a change card with
schema-less arguments and failed on apply (#2967); it now goes back to the
model with the group loaded. Calls answered as not run are now listed on
the assistant turn too, so a thread replayed on another provider has no
orphan tool results. The blind-call hint only goes with the blind call.
2026-11-05 14:36:21 +01:00
Maurice 91f6430762 test(collections): read a row's +N without waiting for one 2026-11-05 14:18:04 +01:00
Maurice 6ee540d5e4 test(collections): hover the +N of a row that has a category beside its labels 2026-11-05 13:53:57 +01:00
Maurice 99cdcfcd09 test(collections): cover fitting a row's labels and folding the map's label strip 2026-11-05 13:31:25 +01:00
Maurice fd11944ed6 test(collections): count the labels a row shows, and give the three-viewport spec room to clean up 2026-11-05 13:29:27 +01:00
Maurice 82c6a3cd5f fix(collections): name a hidden label's class without a leading space prettier strips 2026-11-05 13:10:15 +01:00
Maurice fbafa270c4 fix(collections): fit a row's labels without setting state in an effect, and bring them back when the row grows
The fitting ran in a layout effect and measured chips the row had already
squeezed, so a row that got wider kept its "+N". Every label now stays in
the row, the ones without room kept out of sight, and the observer that
sees the row change size fits them from each chip's full width. The name
takes the width it needs on a wide list and the labels fill the rest.
2026-11-05 13:01:49 +01:00
Maurice 1c54d7d6c1 fix(collections): give place rows two fixed lines and fit labels to the room
The first fix let a row wrap freely, which on a list beside the map put
the status on a line of its own and broke every row differently. A row is
now laid out the same way each time: on a list narrower than 760px the
name, address and status take the first line and the category and labels
one line below; a wider list keeps them on one line. The row shows as
many labels as it has room for, category first, and a "+N" chip names
the rest in its tooltip, so a wide screen shows every label. Chips that
cut a name short say it in full in their tooltip.

The label strip over the map hid the labels that did not fit behind a
scroll with no scrollbar. It now folds into a "Labels" button that opens
them as a list when the bar has no room for all of them, and unfolds
again when the room is back. The filter row stays on one line on a
desktop, a dropdown at its default keeping only its icon when narrow, and
the select bar's actions keep their icons with the words in a tooltip
when the bar is too narrow for them.
2026-11-05 12:46:16 +01:00
Maurice d2c7d83256 fix(collections): keep a place's name readable next to long labels
A place row was a three-column grid whose last column held the labels,
the category and the status, none of which could shrink. With two or
three long labels the name got what was left, often one letter or
nothing, and the status pill ran out of the card.

The row now wraps: the name keeps at least 11rem, and when the labels,
category and status do not fit beside it they go to a line of their own
under it and wrap there. A label or category too long for its chip ends
in an ellipsis instead of being cut mid-letter. Kept tours use the same
row and get the same layout.

Closes #2981
2026-11-05 12:33:05 +01:00
Maurice 91630dd811 fix(planner): let a timed booking stay where it is dropped when only other bookings stand against its time
A booking dropped against another booking's time went back to the nearest
slot its own time allowed. With a 06:30 flight listed below the day's
untimed stops, every slot among them was against the flight, so a 13:00
transit dropped between two stops went back behind the flight on every
drop, and the flight's legs cannot be dragged out of the way.

The dropped row now stays where it was dropped and the other timed
bookings it puts out of order move next to it, the earlier ones ahead of
it and the later ones behind. Timed stops and notes never move: against
one of those the row is still seated by its time as before (#2631). The
phone's move applies the same rule before it refuses a move.

Closes #2983
2026-11-05 12:15:53 +01:00
Maurice 14c767a675 fix(client): keep the open-at rule on the client's library target, and settle CI
Array.prototype.at is past the client's lib target, so the journey open-at
rule indexes the array. The packing row test expects the title that names
every bringer, a carousel test acts without a floating promise, and the
lint baseline drops to the counts this branch reaches.
2026-11-04 19:07:39 +01:00
Maurice da324121bd fix(packing): name everyone who brings a shared item
A common item brought by several members showed the first bringer's avatar
and a bare "+2". The tooltip only named the first person, and on the phone
neither the row nor the item sheet said who else brings it (discussion
#2782).

packingBringerNames lists the bringer and every contributor; the desktop
row, the phone row and the phone item sheet use it for "brought by".
2026-11-04 17:53:15 +01:00
Maurice b41f6e3542 fix(journey): a newest-first share link opens on the latest day on the phone
The public journey page on a phone shows the map with a card carousel, which
always opened on the first entry. The owner's "newest first" choice for the
link was ignored there, so readers following a running trip had to swipe
through every day to reach the latest one (discussion #2851).

The rule for where a journey opens moves out of the journal hook into
journeyOpenAtEntryId, which the public page now uses as well: a newest-first
link opens on the latest day, otherwise a running journey opens on today as
the journal already does.

The carousel also settles on the card at the end it is scrolled to. The last
card can never reach the centre, so opening on the latest day used to
activate the day before it, in the journal as well.
2026-11-04 17:53:15 +01:00
Maurice 9f5d01ee09 test(shared): cover the stored Costs category and the hint the MCP reads 2026-11-04 15:29:29 +01:00
Maurice 67ca58f2a5 fix(mcp): a transport priced without a category is stored under its Costs key 2026-11-04 15:12:20 +01:00
Maurice 9eab19ea6c fix(mcp): hold two more tests to the fixed Costs keys
The budget prompt and the price link of a new transport now read the stored key.
2026-11-04 15:00:03 +01:00
Maurice 50ea9a74c9 fix(mcp): keep budget categories to the Costs keys, and say what a journal entry's visibility does
An assistant writing expenses through the MCP was told "e.g. Accommodation, Food,
Transport" and stored exactly that, so "Food" and "food" sat side by side in the
MCP totals while the Costs tab already read both as one. A typed category in any
case, or a common name like Hotel or Flight, is now stored as its fixed key on
every path (REST, MCP, linked booking costs), the totals the MCP reads group by
that key, and the tools name the real keys. The client reads categories through
the same shared mapping. Any other text is still kept as given.

The journal tools said that a private entry stays off the share link. It never
did: the share link shows every entry that is not a draft, whatever its
visibility, and the web editor has no visibility at all. The tools now say so
and point to is_draft, which is what keeps an entry to the contributors.

The wiki explains why the location button needs HTTPS and how to run TREK behind
an HTTPS tunnel while still using it over plain HTTP at home, and that Android's
photo picker drops a photo's location while Immich and Synology keep it.
2026-11-04 14:26:25 +01:00
205 changed files with 3629 additions and 779 deletions
+1 -1
View File
@@ -56,5 +56,5 @@ See `values.yaml` for more options.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands 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
View File
@@ -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"
}
}
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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;
+5 -5
View File
@@ -4,14 +4,14 @@ import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { 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 -83
View File
@@ -3,7 +3,7 @@ import DOM from 'react-dom'
import { categoryIconSvg } from './categoryIconSvg'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, 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) => (
+136
View File
@@ -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>
);
}
@@ -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
@@ -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 }),
+11 -3
View File
@@ -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);
});
});
+26 -10
View File
@@ -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);
+12 -1
View File
@@ -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')
})
})
+10 -5
View File
@@ -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>}
+10 -10
View File
@@ -294,24 +294,24 @@ function CollectionsPageDesktop(): React.ReactElement {
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
<div className="col-toolbar-spacer" />
{c.canEdit && isRealList && (
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<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')}>
+2
View File
@@ -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
+9 -11
View File
@@ -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,
}
}
+71 -4
View File
@@ -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 ----------------- */
+25 -26
View File
@@ -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);
});
});
+39 -31
View File
@@ -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.
+100 -5
View File
@@ -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
View File
@@ -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
}
}
+30 -1
View File
@@ -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);
});
});
+16
View File
@@ -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 },
+170 -135
View File
@@ -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"
-1
View File
@@ -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,
+19 -7
View File
@@ -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
+1 -1
View File
@@ -7,7 +7,7 @@
"src/db/repositories/Users.repository.ts": 1445,
"src/nest/atlas/atlas.service.ts": 1177,
"src/nest/auth/auth.service.ts": 1143,
"src/nest/budget/budget.service.ts": 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));
}
}
+6 -1
View File
@@ -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;
}
+14 -4
View File
@@ -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);
+38 -13
View File
@@ -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;
}
}
+13 -4
View File
@@ -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' });
}
}
+77 -1
View File
@@ -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 };
}
+4 -4
View File
@@ -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
+24 -33
View File
@@ -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;
}
+1 -1
View File
@@ -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. */
@@ -99,7 +99,8 @@ export class PlaceContribService {
if (!anyField(request.fields)) throw badRequest('Pick at least one field to share', { reason: 'no_field' });
// A new agreement is a fresh start: a pause over a key or a wait the
// service asked for is tried again on the next run, and what users
// typed before the agreement goes out too, caught up page by page.
// typed before the agreement goes out too, caught up page by page,
// withdrawn fields included, since the admin agreed to that anew.
await this.uow.transactional(async () => {
await this.store.writeConsent({
enabled: true,
@@ -177,9 +178,13 @@ export class PlaceContribService {
reason: 'withdraw_all',
}),
);
// Everything taken back stays back: the catch-up does not send it again.
// Everything taken back stays back: the catch-up running now ends, and a
// field switched on later under this agreement does not catch up either.
// Only a new agreement shares the earlier values again.
const cleared = await this.uow.transactional(async () => {
await this.log.insertLines(lines);
const consent = await this.store.consent();
await this.store.writeConsent({ ...consent, withdrawnFields: [...PLACE_CONTRIB_FIELDS] });
await this.store.writeBackfill(null);
return this.outbox.deleteAll();
});
@@ -235,17 +240,33 @@ export class PlaceContribService {
if (!anyField(fields)) throw badRequest('Pick at least one field to share', { reason: 'no_field' });
const removed = PLACE_CONTRIB_FIELDS.filter((field) => consent.fields[field] && !fields[field]);
const added = PLACE_CONTRIB_FIELDS.filter((field) => !consent.fields[field] && fields[field]);
const catchUp = added.length > 0 && consent.includesEarlier === true;
const withdrawn = PLACE_CONTRIB_FIELDS.filter(
(field) => consent.withdrawnFields?.includes(field) || (opts.withdraw && removed.includes(field)),
);
// A field switched on catches up too, under an agreement that said so,
// unless its earlier values were withdrawn: those stay taken back.
const earlier = consent.includesEarlier === true ? added.filter((field) => !withdrawn.includes(field)) : [];
const catchUp = earlier.length > 0;
await this.uow.transactional(async () => {
await this.store.writeConsent({ ...consent, fields, ...opts.decided });
await this.store.writeConsent({
...consent,
fields,
...opts.decided,
...(withdrawn.length > 0 ? { withdrawnFields: withdrawn } : {}),
});
await this.outbox.deleteFields(removed);
// A field switched on catches up too, under an agreement that said so.
const pending = await this.store.backfill();
const pendingFields = (pending?.fields ?? []).filter((field) => !withdrawn.includes(field));
// The catch-up keeps one position, so it starts over for every field it
// still had to do; what went out already is answered as a duplicate.
if (catchUp) {
const pending = (await this.store.backfill())?.fields ?? [];
const catchUpFields = PLACE_CONTRIB_FIELDS.filter((field) => added.includes(field) || pending.includes(field));
const catchUpFields = PLACE_CONTRIB_FIELDS.filter(
(field) => earlier.includes(field) || pendingFields.includes(field),
);
await this.store.writeBackfill({ fields: catchUpFields, afterId: 0 });
} else if (pending && pendingFields.length < pending.fields.length) {
// A field withdrawn while its catch-up was still under way leaves it.
await this.store.writeBackfill(pendingFields.length > 0 ? { ...pending, fields: pendingFields } : null);
}
});
if (catchUp) this.job.sendSoon();
@@ -2,6 +2,7 @@ import { PLACE_DETAILS_CACHE_PAYLOAD } from '../../db/json-columns';
import type { CachedProviderPayload, PlaceShareableRow } from '../../db/repositories/PlaceFieldOrigin.repository';
import { decodeJson } from '../../utils/json-column';
import {
PLACE_CONTRIB_COLUMN,
PLACE_CONTRIB_FIELDS,
normalizePlaceWebsite,
type PlaceContribField,
@@ -28,20 +29,11 @@ export interface ProviderValue {
origin: PlaceFieldOrigin;
}
const COLUMN: Record<PlaceContribField, keyof PlaceShareableRow> = {
website: 'website',
phone: 'phone',
email: 'email',
opening_hours: 'opening_hours',
description: 'description',
image: 'image_url',
};
/** The place's shareable values as stored, an empty text read as none. */
export function snapshotOf(row: PlaceShareableRow): OriginSnapshot {
const snapshot = {} as OriginSnapshot;
for (const field of PLACE_CONTRIB_FIELDS) {
const value = row[COLUMN[field]];
const value = row[PLACE_CONTRIB_COLUMN[field]];
snapshot[field] = typeof value === 'string' && value.trim() !== '' ? value : null;
}
return snapshot;
@@ -17,7 +17,7 @@ import { AssignmentsService } from '../assignments/assignments.service';
import {
idSchema, transportLegInputSchema, reservationUrlSchema, type TransportLegInput, bookingPriceSchema, bookingBudgetCategorySchema,
TRANSPORT_RESERVATION_TYPES, MCP_CREATABLE_TRANSPORT_TYPES, LEG_RESERVATION_TYPES, BOOKING_RESERVATION_TYPES,
RESERVATION_STATUSES, type ReservationStatus,
RESERVATION_STATUSES, type ReservationStatus, typeToCostCategory,
} from '@trek/shared';
import { RESERVATION_METADATA } from '../../db/json-columns';
import { decodeJson } from '../../utils/json-column';
@@ -409,7 +409,7 @@ export class ReservationsMcp {
notes, day_id, place_id, assignment_id,
create_accommodation: createAccommodation,
metadata,
}, linkedCost(price, budget_category || type), await this.guards.tripWriter(tripId, ctx.userId));
}, linkedCost(price, budget_category || typeToCostCategory(type)), await this.guards.tripWriter(tripId, ctx.userId));
return ok(sameFare ? { reservation, warning: sameFareWarning(sameFare) } : { reservation });
}
@@ -749,7 +749,7 @@ export class ReservationsMcp {
metadata: Object.keys(meta).length > 0 ? meta : undefined,
endpoints: transportEndpoints,
needs_review,
}, linkedCost(price, budget_category || type), await this.guards.tripWriter(tripId, ctx.userId));
}, linkedCost(price, budget_category || typeToCostCategory(type)), await this.guards.tripWriter(tripId, ctx.userId));
return ok(sameFare ? { reservation, warning: sameFareWarning(sameFare) } : { reservation });
}
@@ -10,7 +10,7 @@ import { keepMirroredPrice } from './reservation-metadata';
import { endpointRows, groupDayPositions, groupEndpointsByReservation } from './reservations.helpers';
import type { Reservation, User } from '../../types';
import { BudgetService } from '../budget/budget.service';
import { typeToCostCategory } from '@trek/shared';
import { isDerivedCostCategory, typeToCostCategory } from '@trek/shared';
import { NotificationsService } from '../notifications/notifications.service';
import { AccommodationsService, noStayMirror, type AccommodationMirror } from '../accommodations/accommodations.service';
import { toRowId, legacyBoundIntegerText } from '../common/row-id';
@@ -1149,7 +1149,7 @@ export class ReservationsService {
const newCat = typeToCostCategory(type);
// RS51. Every linked expense (#2084), each only while it still has the derived category.
const linked = oldCat === newCat ? [] : await this.budgetItemsRepo.listIdAndCategoryByReservation(tripId, id);
for (const item of linked.filter(i => i.category === oldCat)) {
for (const item of linked.filter(i => isDerivedCostCategory(i.category, currentType))) {
const updated = await this.budget.updateBudgetItem(item.id, tripId, { category: newCat });
this.sendCost(tripId, { event: 'budget:updated', payload: { item: updated } }, socketId, collect);
}

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