Compare commits

...

18 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
172 changed files with 2433 additions and 613 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`.
+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"
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
"trek/no-swallowed-catch": 128,
"max-params": 32,
"trek/store-without-selector": 67,
"react-hooks/refs": 554,
"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,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>
+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; the title names everyone (#2782).
expect(screen.getByTitle('brought by Bob, cleo')).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,7 +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 { packingBringerNames } from './packingBringers'
import { bringersOf, packingBringerNames } from './packingBringers'
import { Tooltip } from '../shared/Tooltip'
import { BringerAvatar, BringersCard, MoreBringers } from './PackingBringersCard'
interface ArtikelZeileProps {
item: PackingItem
@@ -78,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)
@@ -114,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
@@ -215,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: packingBringerNames(item) })} aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
{ownerAvatar
? <img src={ownerAvatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover', display: 'block' }} />
: (
<span aria-hidden style={{
width: 20, height: 20, borderRadius: '50%', display: 'grid', placeItems: 'center',
background: `hsl(${(broughtBy.codePointAt(0) ?? 0) * 37 % 360}, 55%, 55%)`, // theme-lint-disable: the colour comes from the bringer's name
color: 'white', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase',
}}>{broughtBy[0]}</span>
)}
{contributors.length > 0 && <span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)' }}>+{contributors.length}</span>}
</span>
<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 }}>
@@ -1,3 +1,5 @@
import type { TripMember } from './usePackingListPanel';
interface BroughtItem {
owner_username?: string | null;
contributors?: { username: string }[] | null;
@@ -12,3 +14,34 @@ 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>
)}
@@ -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 {
+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')}>
+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.
+53 -4
View File
@@ -716,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', () => {
+61 -13
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)
@@ -472,6 +485,41 @@ export function yieldTimedBookings(
return drawnAsIs(result, dayId, getDisplayTime) ? result : null
}
/**
* The timed stops of the day that sit within a day's reach of an endpoint of the zoned
* bookings, with the instant their clock names in that endpoint's zone. The date is the
* one the bookings' times carry on this day; bookings that disagree on it leave every
* stop on its clock.
*/
function stopsInZone(
items: MergedItem[],
bookings: readonly { it: MergedItem; date: string }[],
clockOf: (it: MergedItem) => number | null
): { it: MergedItem; index: number; clock: number; instant: number }[] {
const dates = new Set(bookings.map(b => b.date))
if (dates.size !== 1) return []
const [date] = dates
const anchors = bookings.flatMap(b => orderedEndpoints(b.it.data))
.filter(e => Number.isFinite(e.lat) && Number.isFinite(e.lng) && !!e.timezone)
if (anchors.length === 0) return []
return items.flatMap((it, index) => {
if (it.type !== 'place') return []
const clock = clockOf(it)
const place = it.data?.place
if (clock === null || !Number.isFinite(place?.lat) || !Number.isFinite(place?.lng)) return []
let zone: string | null = null
let nearest = MAX_DAY_TRIP_KM
for (const e of anchors) {
const km = haversineKm({ lat: place.lat, lng: place.lng }, { lat: e.lat, lng: e.lng })
if (km <= nearest) { nearest = km; zone = e.timezone as string }
}
const hh = String(Math.floor(clock / 60)).padStart(2, '0')
const mm = String(clock % 60).padStart(2, '0')
const instant = zone ? zonedInstant(`${date}T${hh}:${mm}`, zone) : null
return instant === null ? [] : [{ it, index, clock, instant }]
})
}
/**
* A day order with a dragged booking or note at `index`, moved to the nearest slot the
* list draws it in. A row with a time is drawn by it, and the untimed rows behind it
+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([])
+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": 1612,
"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 };
}
+20 -28
View File
@@ -31,7 +31,7 @@ import { BUDGET_ITEM_TICKET, RESERVATION_METADATA } from '../../db/json-columns'
import { decodeJson, decodeJsonResult, encodeJson } from '../../utils/json-column';
import { DomainError } from '../common/domain-error';
import { GUEST_HAS_PAYMENTS, shareAfterLeaving } from './budget-departure.helpers';
import { 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;
@@ -91,7 +91,8 @@ export function splitLegacyTicketNote(
* queries instead of a full listSettlements() scan, settlement usernames use
* COALESCE(display_name, username) like every item query, and updateMembers no
* longer double-applies avatarUrl.
*/@Injectable()
*/
@Injectable()
export class BudgetService {
constructor(
private readonly permissions: PermissionsService,
@@ -1082,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>();
@@ -1136,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());
@@ -1169,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;
@@ -1194,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;
}
}
@@ -1225,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,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);
}
@@ -14,6 +14,7 @@ import { DatabaseLifecycle } from '../database/database-lifecycle.service';
import { withRequestContext } from '../database/request-context';
import { StorageInstallService } from './storage-install.service';
import { StorageJobsService } from './storage-jobs.service';
import { isValidKey } from './storage-keys';
import { StorageReclaimGuardService } from './storage-reclaim-guard.service';
import { StorageReclaimRegistryService } from './storage-reclaim-registry.service';
import { inReclaimScope, RECLAIM_LEDGER, reclaimableCategories, sharedLocationsOf } from './storage-reclaim.ledger';
@@ -435,7 +436,10 @@ export class StorageReclaimService implements OnModuleInit {
for await (const stat of this.storage.list(category, '', { shallow: true })) {
seen += 1;
if (seen % LIST_CHECK_EVERY === 0) this.assertSameDatabase(ctx);
if (!inReclaimScope(stat.key)) continue;
// A name the storage layer refuses (a backslash or a control character in a
// local file put there by hand) can never be stat'ed or deleted: judging it
// would fail its whole delete batch every night, so it is left alone.
if (!inReclaimScope(stat.key) || !isValidKey(stat.key)) continue;
const owner = storageOwnerMarkerInstall(stat.key);
if (owner !== null) {
owners.add(owner);
@@ -78,7 +78,9 @@ class ReleaseStopped extends Error {}
/**
* What a user's change left unnamed: a replaced or removed cover, place image
* or avatar, a deleted journey photo or trip file. The owner hands the values
* or avatar, a deleted journey photo or trip file, and what a deleted list
* (its cover and saved places' images) or journey (its gallery's photos) held;
* a deleted trip hands on only its cover. The owner hands the values
* its rows held to `releaseValues` once its transaction committed, and every
* object they name is queued, which is all the request pays. Nothing goes
* before RELEASE_DELAY_MS, so an undo or a late write that brings the value
+2 -2
View File
@@ -284,8 +284,8 @@ export class TransitMcp {
}
// The fare files the linked expense in the booking's own transaction and
// rides along in the metadata, as create_transport's price does. `transit`
// is no Costs category (typeToCostCategory would file it under other), so a
// fare defaults to the one every rail and bus booking maps to.
// is no Costs category of its own, so a fare defaults to the one every rail
// and bus booking maps to (typeToCostCategory('transit') says the same).
const linkedCost =
price != null && price > 0 ? { total_price: price, category: budget_category || 'transport' } : undefined;
const { reservation } = await this.writes.createBooking(
+4 -3
View File
@@ -36,9 +36,10 @@ export interface SsrfResult {
* that many hosts cannot reach on port 443 while the A record would have
* answered at once. Every address the guard allows goes to the socket, where
* Node tries the addresses in turn (autoSelectFamily, the default since Node
* 20), so an unreachable family costs a quarter of a second instead of the
* caller's whole timeout. IPv4 leads because that is the family that is
* reachable from the containers and LXCs TREK usually runs in.
* 20), so an unreachable family costs at most one connect attempt per address
* (CONNECT_ATTEMPT_TIMEOUT_MS, 2 s, raised at startup in utils/connectAttempt.ts)
* instead of the caller's whole timeout. IPv4 leads because that is the family
* that is reachable from the containers and LXCs TREK usually runs in.
*/
async function resolveAll(hostname: string): Promise<{ address: string; family: number }[]> {
const result = await dns.lookup(hostname, { all: true });
@@ -93,6 +93,7 @@ export async function accommodationsOver(
await createTestJourneyPhotosRepo(conn),
await createTestJourneyEntryPhotosRepo(conn),
await createTestPlacesRepo(conn),
{ releaseValues: async () => 0 } as never, // StorageReleaseService
),
await createTestUnitOfWork(conn),
await createTestDayAssignmentsRepo(conn),
+1
View File
@@ -361,6 +361,7 @@ export async function createPluginRpcHostParts(
await createTestJourneyPhotosRepo(db),
await createTestJourneyEntryPhotosRepo(db),
await createTestPlacesRepo(db),
{ releaseValues: async () => 0 } as never, // StorageReleaseService
);
const atlas = new AtlasService(
await createTestBucketListRepo(db),
+13 -2
View File
@@ -61,16 +61,27 @@ describe('entrypoint script', () => {
it('never walks the volumes on a start where they already belong to node', () => {
// Only the dir and its direct entries are looked at, never the whole tree.
expect(code).toContain('find "$dir" -maxdepth 1 \\( ! -user node -o ! -group node \\) -print -quit');
expect(code).toContain('find "$dir" -maxdepth 1 \\( ! -user node -o -group 0 \\) -print -quit');
expect(code).not.toMatch(/find "\$dir" \\\(/);
// The recursive hand-over sits behind that check and behind a chown of the dir that worked.
const check = code.indexOf('-maxdepth 1');
const dirChown = code.indexOf('if chown node:node "$dir"');
const dirChown = code.indexOf('if chown node "$dir"');
expect(dirChown).toBeGreaterThan(check);
expect(code.indexOf('chown -R')).toBeGreaterThan(dirChown);
expect(code.indexOf('find "$dir" -group 0 -exec chgrp -h node {} +')).toBeGreaterThan(code.indexOf('chown -R'));
expect(code).toContain('Correcting ownership of $dir for the node user...');
});
// A Kubernetes fsGroup other than 1000 gives every file that gid. Handing the
// group back to node would leave the kubelet's OnRootMismatch with a mismatched
// root on the next start, and the two recursive walks would undo each other on
// every start (#2918 again). So only the user is taken over, plus a root group.
it('takes over the owning user and a root group, never a foreign fsGroup', () => {
expect(code).not.toContain('-group node');
expect(code).not.toContain('node:node');
expect(code).toContain('chown -R -h node "$dir"');
});
it('says so once instead of walking a volume that refuses chown', () => {
expect(code).toContain('cannot be handed to the node user (chown refused); run the container as uid 1000 instead.');
});
@@ -93,6 +93,21 @@ describe('Tool: create_reservation', () => {
});
});
it('files the cost of a priced booking under its Costs key when no budget_category is given', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_reservation',
arguments: { tripId: trip.id, title: 'Jazz night', type: 'event', price: 40 },
});
const data = parseToolResult(result) as { reservation: { id: number } };
const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id });
// Not a separate "event" bucket next to other: the key the web form files it under.
expect(item?.category).toBe('activities');
});
});
it('creates a parking reservation (#1444)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -434,6 +434,22 @@ describe('AddonsService addon/feature flags', () => {
expect((await svc.list()).toursWanderer).toBe(false);
});
it('wanderer is never available on a managed install, whatever the stored switch says', async () => {
const saved = process.env.TREK_MANAGED;
try {
await insertAddon({ id: 'tours', name: 'Tours', enabled: 1 });
await svc.updateToursWanderer(true);
expect(await svc.isWandererAvailable()).toBe(true);
process.env.TREK_MANAGED = '1';
expect(await svc.isWandererAvailable()).toBe(false);
expect((await svc.list()).toursWanderer).toBe(false);
} finally {
if (saved === undefined) delete process.env.TREK_MANAGED;
else process.env.TREK_MANAGED = saved;
}
});
it('OpenHikingMap under Tours is on until an admin switches it off, and offered only with Tours (MAP-01)', async () => {
expect(await svc.getToursHikingMap()).toEqual({ enabled: true }); // absent row → ON
expect((await svc.list()).toursHikingMap).toBe(false); // Tours is off
@@ -109,6 +109,7 @@ beforeAll(async () => {
await createTestJourneyPhotosRepo(testDb),
await createTestJourneyEntryPhotosRepo(testDb),
await createTestPlacesRepo(testDb),
{ releaseValues: async () => 0 } as never, // StorageReleaseService
),
await createTestUnitOfWork(testDb),
await createTestDayAssignmentsRepo(testDb),
@@ -859,6 +859,49 @@ describe('runAgentLoop', () => {
expect(outcome).toMatchObject({ end: 'failed', code: 'empty_answer' });
expect(t.requests).toHaveLength(2);
});
it('ASTAGT-045: follow-ups with no answer after an announcing first answer are still asked for the answer', async () => {
const t = setup([
[{ type: 'text-delta', delta: 'Let me check your places:' }],
[{ type: 'tool-call', call: { id: 'l1', name: 'list_places', args: {} } }],
[ask('toolu_8', ['Add Ginkaku-ji']), { type: 'finish', reason: 'tool-calls' }],
[{ type: 'text-delta', delta: 'Kinkaku-ji is the only place so far.' }],
]);
const outcome = await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal);
expect(outcome.end).toBe('stop');
expect(t.requests).toHaveLength(4);
expect(t.requests[1].messages.at(-1)).toMatchObject({ role: 'user', text: expect.stringContaining('now') });
expect(t.requests[3].messages.at(-1)).toMatchObject({
role: 'tool',
toolCallId: 'toolu_8',
output: expect.stringContaining('Not shown yet'),
});
expect(t.chunks).toContainEqual({ type: 'data-trek-follow-ups', data: { questions: ['Add Ginkaku-ji'] } });
expect(t.memory.saved.map((saved) => saved.status)).not.toContain('error');
});
});
it('ASTAGT-046: a change its session sends back (called before its tools were loaded) is a tool error, never held (#2967)', async () => {
const t = setup([
[{ type: 'tool-call', call: { id: 'w1', name: 'create_place', args: { name: 'Blind' } } }],
[{ type: 'tool-call', call: { id: 'w2', name: 'create_place', args: { name: 'With schema' } } }],
]);
t.tools.pinArgs.mockImplementationOnce(() => ({ error: 'create_place did not run: send it again' }));
const outcome = await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal);
expect(outcome.end).toBe('awaiting');
expect(t.memory.saved[0].step.parts).toContainEqual({
type: 'tool-call',
toolCallId: 'w1',
toolName: 'create_place',
args: { name: 'Blind' },
result: 'create_place did not run: send it again',
isError: true,
});
const sentBack = t.requests[1].messages.find((m) => m.role === 'tool' && m.toolCallId === 'w1');
expect(sentBack).toMatchObject({ isError: true, output: expect.stringContaining('did not run') });
expect(t.deps.describe).toHaveBeenCalledTimes(1);
expect(t.memory.held).toHaveLength(1);
expect(t.memory.held[0].held.map((call) => call.args)).toEqual([{ name: 'With schema' }]);
});
it('ASTAGT-038: followUpsOf trims, drops duplicates and blanks, cuts each to a chip and keeps three', () => {
@@ -651,6 +651,64 @@ describe('toChatHistory: calls the model made of the server tools (#2845)', () =
expect(outputs).toEqual(['call_1', 'call_2']);
});
it('ASTHIS-021: a stopped step replayed on another provider names every call it answers', () => {
const anthropic = stoppedMidStep({
adapter: 'anthropic-messages',
blocks: [
{ type: 'text', text: 'Let me look.' },
{ type: 'tool_use', id: 'call_1', name: 'list_places', input: {} },
{ type: 'tool_use', id: 'call_2', name: 'get_weather', input: { day: 3 } },
],
});
expect(anthropic.find((message) => message.role === 'assistant')).toMatchObject({
toolCalls: [
{ id: 'call_1', name: 'list_places', args: {} },
{ id: 'call_2', name: 'get_weather', args: { day: 3 } },
],
});
// Chat Completions: every tool message follows an assistant message whose tool_calls name it.
const chat = toChatMessages({ system: '', messages: anthropic, tools: [] });
chat.forEach((message, index) => {
if (message.role !== 'tool') return;
const before = chat
.slice(0, index)
.reverse()
.find((earlier) => earlier.role === 'assistant');
const named = ((before?.tool_calls ?? []) as Array<{ id: string }>).map((call) => call.id);
expect(named).toContain(message.tool_call_id);
});
// Responses: every function_call_output has its function_call before it.
const items = toResponsesInput({ system: '', messages: anthropic, tools: [] });
items.forEach((item, index) => {
if (item.type !== 'function_call_output') return;
const made = items
.slice(0, index)
.some((earlier) => earlier.type === 'function_call' && earlier.call_id === item.call_id);
expect(made).toBe(true);
});
// Anthropic: a step the Responses API recorded is rebuilt with every tool_use its results answer.
const responses = stoppedMidStep({
adapter: 'openai-responses',
items: [
{ type: 'function_call', call_id: 'call_1', name: 'list_places', arguments: '{}' },
{ type: 'function_call', call_id: 'call_2', name: 'get_weather', arguments: 'not json' },
],
});
const messages = toAnthropicMessages(responses);
const uses = messages[1].content.filter((block) => block.type === 'tool_use');
expect(uses.map((block) => [block.id, block.input])).toEqual([
['call_1', {}],
['call_2', {}],
]);
const results = messages[2].content
.filter((block) => block.type === 'tool_result')
.map((block) => block.tool_use_id);
expect(results).toEqual(['call_1', 'call_2']);
});
it('ASTHIS-017: a step that only kept a note is the model call it was, answered with what was kept', () => {
const history = toChatHistory(
[
@@ -425,6 +425,79 @@ describe('an assistant tool session', () => {
}
});
it('ASTTOOL-025: a change called before its group was loaded is sent back until a step was offered its schema (#2967)', async () => {
const { userId, tripId } = await ownerWithTrips();
const session = await tools.open(userId, tripId);
try {
expect(session.specs().map((spec) => spec.name)).not.toContain('insert_tour_stop');
// Held as a card, the blind arguments would only fail once the user applies it.
expect(session.pinArgs('insert_tour_stop', { placeId: '530' })).toEqual({
error: expect.stringContaining('"tours" tools, which were not loaded in this turn, so it did not run'),
});
// A second change of the same step was written without the schema too.
expect(session.pinArgs('create_city_walk', { stations: '[1, 2]' })).toEqual({
error: expect.stringContaining('did not run'),
});
// The next step is offered the schema, and the call goes through.
expect(session.specs().map((spec) => spec.name)).toContain('insert_tour_stop');
expect(session.pinArgs('insert_tour_stop', { placeId: 530 })).toHaveProperty('args');
} finally {
await session.close();
}
});
it('ASTTOOL-028: a change called before its group was loaded is held as always when its arguments already fit', async () => {
const { userId, tripId } = await ownerWithTrips();
const session = await tools.open(userId, tripId);
try {
session.specs();
const fits = { placeId: 530, expected_revision: 3, stop: { name: 'Hut', lat: 47.1, lng: 11.2 } };
expect(session.pinArgs('insert_tour_stop', fits)).toEqual({ args: fits });
// Written as text, the same call is still sent back with the schema loaded.
expect(session.pinArgs('create_city_walk', { stations: '[1, 2]' })).toEqual({
error: expect.stringContaining('did not run'),
});
} finally {
await session.close();
}
});
it('ASTTOOL-026: only the blind call itself is told about the schema; a later failure of the same tool is not', async () => {
const { userId, tripId } = await ownerWithTrips();
const session = await tools.open(userId, tripId);
try {
session.specs();
expect(session.pinArgs('get_tour_plan', { placeId: '530' })).toEqual({ args: { placeId: '530' } });
const blind = await session.call('get_tour_plan', { placeId: '530' });
expect(String(blind.value)).toContain('"tours" tools were loaded in this turn');
// Sent again after the next step's specs: it fails on its own, without the hint.
session.specs();
expect(session.pinArgs('get_tour_plan', { placeId: 999_999 })).toEqual({ args: { placeId: 999_999 } });
const again = await session.call('get_tour_plan', { placeId: 999_999 });
expect(again.ok).toBe(false);
expect(String(again.value)).not.toContain('were loaded in this turn');
} finally {
await session.close();
}
});
it('ASTTOOL-027: a blind read refused before it ran leaves no hint for a later call of the same tool', async () => {
const { userId, tripId } = await ownerWithTrips();
const session = await tools.open(userId, tripId);
try {
session.specs();
// Called blind, then refused by the loop before it ran: call() never sees it.
expect(session.pinArgs('get_tour_plan', { placeId: '530' })).toHaveProperty('args');
session.specs();
expect(session.pinArgs('get_tour_plan', { placeId: 999_999 })).toEqual({ args: { placeId: 999_999 } });
const run = await session.call('get_tour_plan', { placeId: 999_999 });
expect(run.ok).toBe(false);
expect(String(run.value)).not.toContain('were loaded in this turn');
} finally {
await session.close();
}
});
it('ASTTOOL-014: an undo session holds its target and nothing else', async () => {
const { userId, tripId } = await ownerWithTrips();
const session = await tools.open(userId, tripId, new Set(['delete_place']));
@@ -1437,6 +1437,27 @@ describe('calculateSettlement: currencies shown in whole units (#2604)', () => {
}
});
it('a bill in a won display currency on a euro trip is split in whole won, and settle-up clears it', async () => {
// Read in won, a won bill counts as typed (#2965) and skips the conversion that
// rounds to whole won, so it has to be split in whole won itself: 3,333.33 each
// printed as -3,333 / -3,333 beside +6,667 again.
const fx = { base: 'KRW', tripCurrency: 'EUR', rates: { EUR: 1, KRW: 1500 } };
const items = [{ ...makeItem(1, 10000), currency: 'KRW', exchange_rate: 1500 } as BudgetItem];
const members = [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')];
const payers = [makePayer(1, 1, 10000, 'alice')];
await setupDb(items, members, payers);
const result = await budget.calculateSettlement(1, fx);
expect(result.currency).toBe('KRW');
expectWholeAndSquare(result);
expect(result.balances.map((b) => b.balance).sort((x, y) => x - y)).toEqual([-3334, -3333, 6667]);
const after = await settleEverything(items, members, payers, fx, 'KRW');
expectWholeAndSquare(after);
expect(after.balances.every((b) => b.balance === 0)).toBe(true);
expect(after.flows).toEqual([]);
});
it('a two-decimal display currency keeps its cents', async () => {
// The euro case the rest of this file pins, read once more beside the won ones:
// 10.00 three ways stays 3.34 / 3.33 / 3.33, not whole euros.
@@ -1629,6 +1650,40 @@ describe('calculateSettlement: bills in the display currency (#2965)', () => {
expect(centSum(later.balances.map((b) => b.balance))).toBe(0);
});
it('settled when the halves point opposite ways, it stays settled when the yen moves', async () => {
// Bob paid a 100 EUR train for both, Alice a 100,000 yen hotel for both: Alice
// owes Bob 50 EUR in euros, Bob owes Alice 50,000 yen. Bob pays the net in euros.
// The euro credit used to stay in euros beside the yen it was netted with, and
// the balance reopened as soon as the rate moved.
const items = () => [
{ ...makeItem(1, 100), currency: 'EUR', exchange_rate: 1 / 176.37 } as BudgetItem,
{ ...makeItem(2, 100000) } as BudgetItem,
];
const members = () => [...pair(), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')];
const payers = () => [makePayer(1, 2, 100, 'bob'), makePayer(2, 1, 100000, 'alice')];
await setupDb(items(), members(), payers());
const offer = (await budget.calculateSettlement(1, today)).flows;
expect(offer).toHaveLength(1);
expect(offer[0].from.user_id).toBe(2);
await setupDb(items(), members(), payers(), [makeSettlementRow(5, 2, 1, offer[0].amount, 'EUR', 1 / 176.73)]);
const results = await Promise.all(
[1 / 176.73, 1 / 160, 1 / 195].map((eurPerYen) =>
budget.calculateSettlement(1, { ...today, rates: { JPY: 1, EUR: eurPerYen } }),
),
);
for (const result of results) {
expect(result.balances.map((b) => b.balance)).toEqual([0, 0]);
expect(result.flows).toEqual([]);
for (const f of result.finalBudgets) {
expect(f.sources.moved.reduce((a, r) => a + r.cents, 0)).toBe(Math.round(f.reimbursed * 100));
expect(f.final).toBeCloseTo(f.expenses - f.reimbursed - f.pending, 2);
}
}
// What Bob sent is what Alice received, read on the day it was sent.
const alice = results[0].finalBudgets.find((f) => f.user_id === 1);
expect(alice?.reimbursed).toBe(offer[0].amount);
});
it('read in the trip currency nothing changes: the bill goes through its frozen rate', async () => {
await setupDb(flight(), pair(), paid());
const result = await budget.calculateSettlement(1, { base: 'JPY', tripCurrency: 'JPY', rates: null });
@@ -700,6 +700,24 @@ describe('deleteCollection', () => {
expect(await placesIn(col.id)).toEqual({ n: 0 });
expect(await membersOf(col.id)).toEqual({ n: 0 });
});
it('COLLECTIONS-SVC-040b: deleting a list releases its cover and the images of its saved places (#2924)', async () => {
const owner = createUser(testDb).user;
const col = await svc.createCollection(owner.id, { name: 'Doomed' });
const files = ['list-cover.jpg', 'list-place-a.jpg', 'list-place-b.jpg'].map((name) => {
const filePath = path.join(storageFx.root, name);
fs.writeFileSync(filePath, 'jpeg-bytes');
return filePath;
});
await svc.setCollectionCover(owner.id, col.id, '/uploads/covers/list-cover.jpg');
await svc.savePlace(owner.id, { collection_id: col.id, name: 'A', image_url: '/uploads/places/list-place-a.jpg' });
await svc.savePlace(owner.id, { collection_id: col.id, name: 'B', image_url: '/uploads/places/list-place-b.jpg' });
await svc.savePlace(owner.id, { collection_id: col.id, name: 'No image' });
await svc.deleteCollection(owner.id, col.id);
expect(files.map((file) => fs.existsSync(file))).toEqual([false, false, false]);
});
});
// ── owner_id semantics: member account deletion keeps shared content ─────────
@@ -153,6 +153,7 @@ beforeAll(async () => {
await createTestJourneyPhotosRepo(testDb),
await createTestJourneyEntryPhotosRepo(testDb),
await createTestPlacesRepo(testDb),
{ releaseValues: async () => 0 } as never, // StorageReleaseService
),
makeStorageFixture('').storage,
await accommodationsOver(testDb),
@@ -77,6 +77,7 @@ beforeAll(async () => {
await createTestJourneyPhotosRepo(testDb),
await createTestJourneyEntryPhotosRepo(testDb),
await createTestPlacesRepo(testDb),
{ releaseValues: async () => 0 } as never, // StorageReleaseService
);
// Plan 3g Task 3: JourneyBooksRepository (JB1-JB7), not `dbs` any more.
// task-5-fix-brief constructor-ripple (M1): `UnitOfWork`, so `saveBook`'s
@@ -0,0 +1,95 @@
/**
* JourneyDomainService: what deleting a journey does with the photos of its gallery
* (#2924). A photo row only this journey linked goes with it and its files are handed
* to the storage release; one a trip or another journey still links keeps its row.
* The service comes from its module over a snapshot test db, the release is a spy.
*/
import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { JourneyDomainModule } from '../../../src/nest/journey/journey-domain.module';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { StorageReleaseService } from '../../../src/nest/storage/storage-release.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { findRows, insertRow } from '../../helpers/factories/rows';
import { makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { releaseNothing } from '../../helpers/storage-release';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import type { EntityClass, EntityDTO, FilterQuery } from '@mikro-orm/core';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const db = createSnapshotTestDb();
const release = releaseNothing();
let t: TestModule;
let orm: TestOrm;
let svc: JourneyDomainService;
async function one<T extends object>(entity: EntityClass<T>, where: FilterQuery<T>) {
return (await findRows(orm, entity, where))[0] as EntityDTO<T> | undefined;
}
beforeAll(async () => {
t = await createTestModule({
db,
imports: [JourneyDomainModule],
overrides: [{ provide: StorageReleaseService, useValue: release }],
});
svc = t.get(JourneyDomainService);
orm = await createTestOrm(db);
});
beforeEach(() => {
resetTestDb(db);
orm.clear();
});
afterAll(async () => {
await orm.close();
await t.close();
db.close();
});
describe('deleteJourney and its gallery', () => {
it('JOURNEY-SVC-022b: deleting a journey drops the photo rows only it held and releases their files', async () => {
const { user } = await makeUser(orm);
const journey = await svc.createJourney(user.id, { title: 'J' });
const [photo, video] = await svc.uploadGalleryPhotos(journey.id, user.id, [
{ path: 'journey/a.jpg' },
{ path: 'journey/b.mp4', thumbnail: 'journey/b-poster.jpg', mediaType: 'video' },
]);
release.releaseValues.mockClear();
expect(await svc.deleteJourney(journey.id, user.id)).toBe(true);
expect(await one(TrekPhotos, { id: { $in: [photo.photo_id, video.photo_id] } })).toBeUndefined();
expect(release.releaseValues).toHaveBeenCalledTimes(1);
const [values, options] = release.releaseValues.mock.calls[0] as unknown as [Array<string | null>, unknown];
expect(values.filter(Boolean).sort()).toEqual(['journey/a.jpg', 'journey/b-poster.jpg', 'journey/b.mp4']);
expect(options).toEqual({ bare: 'journey' });
});
it('JOURNEY-SVC-022c: a photo another journey or a trip still links keeps its row through the delete', async () => {
const { user } = await makeUser(orm);
const journey = await svc.createJourney(user.id, { title: 'J' });
const other = await svc.createJourney(user.id, { title: 'Other' });
const trip = await makeTrip(orm, user.id);
const [shared, inTrip, alone] = await svc.uploadGalleryPhotos(journey.id, user.id, [
{ path: 'journey/shared.jpg' },
{ path: 'journey/trip.jpg' },
{ path: 'journey/alone.jpg' },
]);
// The same file uploaded to a second journey reuses its photo row.
const [again] = await svc.uploadGalleryPhotos(other.id, user.id, [{ path: 'journey/shared.jpg' }]);
expect(again.photo_id).toBe(shared.photo_id);
await insertRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: inTrip.photo_id, shared: 1 });
expect(await svc.deleteJourney(journey.id, user.id)).toBe(true);
expect(await one(TrekPhotos, { id: shared.photo_id })).toBeDefined();
expect(await one(TrekPhotos, { id: inTrip.photo_id })).toBeDefined();
expect(await one(TrekPhotos, { id: alone.photo_id })).toBeUndefined();
expect(await one(JourneyPhotos, { journey: other.id, photo: shared.photo_id })).toBeDefined();
});
});
@@ -52,6 +52,7 @@ import {
// ported from the reviewer's probe (scratchpad r3g/probe/zz-r3g-parity-probe.test.ts).
import { todayUtc } from '@trek/shared';
import { GALLERY_CHRONOLOGICAL_ORDER } from '../../helpers/legacy-gallery-order';
import { releaseNothing } from '../../helpers/storage-release';
import type { EntityClass, EntityDTO, FilterQuery, FindOptions, RequiredEntityData } from '@mikro-orm/core';
import { countRows, deleteRows, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
@@ -98,6 +99,7 @@ async function pickAll<T extends object, K extends keyof EntityDTO<T>>(
}
let svc: JourneyDomainService;
const release = releaseNothing();
// Plan 3g Task 1's own additions (below, "repositories (R9's parity + mutation
// proofs)" describe block) reach the repositories directly — held here so
// that block doesn't need its own second construction.
@@ -140,11 +142,9 @@ beforeAll(async () => {
svc = new JourneyDomainService(
new RealtimeService(), new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), photosRepoDirect), await createTestUnitOfWork(testDb),
journeysRepoDirect, contributorsRepoDirect, journeyTripsRepoDirect, entriesRepoDirect, tripsRepoDirect,
// Plan 3g Task 2's own append to this SAME construction call — the two
// repositories this task builds, plus `PlacesRepository` (JG44) — per
// R9, Task 2 only appends to what Task 1's construction already
// established, never re-touches the wrapping mechanism itself.
// The photos surface (JG87-116), `PlacesRepository` (JG44) and the storage release.
photosRepoDirect, entryPhotosRepoDirect, placesRepoDirect,
release,
);
});
@@ -101,6 +101,7 @@ beforeAll(async () => {
await createTestJourneyPhotosRepo(testDb),
await createTestJourneyEntryPhotosRepo(testDb),
await createTestPlacesRepo(testDb),
{ releaseValues: async () => 0 } as never, // StorageReleaseService
),
new SettingsService(
uow,
@@ -108,6 +108,7 @@ beforeAll(async () => {
await createTestJourneyPhotosRepo(testDb),
await createTestJourneyEntryPhotosRepo(testDb),
await createTestPlacesRepo(testDb),
{ releaseValues: async () => 0 } as never, // StorageReleaseService
);
});
@@ -4,7 +4,7 @@ import { StorageEventsService } from '../../../../src/nest/storage/storage-event
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
// L4 (task-7-review.md): `orm` is now a required constructor param — in
// production it is always injected, so `@Optional()` was a fail-open seam
@@ -21,12 +21,24 @@ afterAll(async () => {
testDb.close();
});
const savedManaged = process.env.TREK_MANAGED;
afterEach(() => {
if (savedManaged === undefined) delete process.env.TREK_MANAGED;
else process.env.TREK_MANAGED = savedManaged;
});
/** The notifier over a fresh event bus and `send`, subscribed the way bootstrap does it. */
function subscribed(send: ReturnType<typeof vi.fn>): StorageEventsService {
const events = new StorageEventsService();
const notifier = new StorageHealthNotifierService(events, { send } as unknown as NotificationsService, t.orm);
notifier.onApplicationBootstrap();
return events;
}
describe('StorageHealthNotifierService', () => {
it('NOTIF-001 subscribes on bootstrap and sends an admin-scoped replica_failure with params', () => {
const events = new StorageEventsService();
const send = vi.fn().mockResolvedValue(undefined);
const notifier = new StorageHealthNotifierService(events, { send } as unknown as NotificationsService, t.orm);
notifier.onApplicationBootstrap();
const events = subscribed(send);
events.emitReplicaFailure({ backend: 's3-bkp', key: 'backups/db.zip', op: 'put', error: 'timeout', at: 1 });
expect(send).toHaveBeenCalledWith({
event: 'replica_failure',
@@ -41,10 +53,16 @@ describe('StorageHealthNotifierService', () => {
});
it('NOTIF-002 a rejected send never throws into the emitter (write path stays safe)', () => {
const events = new StorageEventsService();
const send = vi.fn().mockRejectedValue(new Error('smtp down'));
const notifier = new StorageHealthNotifierService(events, { send } as unknown as NotificationsService, t.orm);
notifier.onApplicationBootstrap();
const events = subscribed(send);
expect(() => events.emitReplicaFailure({ backend: 'b', key: 'k', op: 'delete', error: 'e', at: 3 })).not.toThrow();
});
it('NOTIF-003 a managed install sends nothing, since its admins have no row to mute the alert', () => {
process.env.TREK_MANAGED = '1';
const send = vi.fn().mockResolvedValue(undefined);
const events = subscribed(send);
events.emitReplicaFailure({ backend: 's3-bkp', key: 'backups/db.zip', op: 'put', error: 'timeout', at: 4 });
expect(send).not.toHaveBeenCalled();
});
});
@@ -11,6 +11,7 @@ import type { AppEnv } from '../../../../src/app-config';
import { PlaceContribLog } from '../../../../src/db/entities/PlaceContribLog.entity';
import { PlaceContribOutbox } from '../../../../src/db/entities/PlaceContribOutbox.entity';
import { PlaceFieldOrigin } from '../../../../src/db/entities/PlaceFieldOrigin.entity';
import type { PlaceContribOutboxRepository } from '../../../../src/db/repositories/PlaceContribOutbox.repository';
import { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
import { servicesConfig } from '../../../../src/nest/app-config/tokens';
import { valueHash } from '../../../../src/nest/common/value-hash';
@@ -135,6 +136,17 @@ async function placeWith(
return id;
}
/** Runs `meanwhile` once the step has read its page, before it queues anything. */
function betweenReadAndWrite(meanwhile: () => Promise<unknown>): void {
const outbox = (backfill() as unknown as { outbox: PlaceContribOutboxRepository }).outbox;
const read = outbox.typedAfter.bind(outbox);
vi.spyOn(outbox, 'typedAfter').mockImplementationOnce(async (...args) => {
const page = await read(...args);
await meanwhile();
return page;
});
}
const queued = async () =>
(await findRows(orm(), PlaceContribOutbox, {})).map((row) => ({ place: row.place_id, field: row.field }));
@@ -266,4 +278,89 @@ describe('PlaceContribBackfill', () => {
await service().decide(ADMIN, { action: 'decline', termsVersion: PLACE_CONTRIB_TERMS_VERSION }, NOW);
expect(await store().backfill()).toBeNull();
});
it('PCB-011: a withdrawal that commits while a step reads its page brings nothing back', async () => {
await placeWith();
await agree();
betweenReadAndWrite(() => service().withdrawAll(NOW));
expect(await backfill().step(NOW)).toBe(0);
expect(await queued()).toEqual([]);
expect(await store().backfill()).toBeNull();
});
it('PCB-012: switching off while a step reads its page queues nothing, and leaves the catch-up ended', async () => {
await placeWith();
await agree();
betweenReadAndWrite(() => service().decide(ADMIN, { action: 'off' }, NOW));
expect(await backfill().step(NOW)).toBe(0);
expect(await queued()).toEqual([]);
expect(await store().backfill()).toBeNull();
});
it('PCB-013: a field switched off while a step reads its page is not queued', async () => {
const id = await placeWith();
await agree();
betweenReadAndWrite(() => service().decide(ADMIN, { action: 'fields', fields: { ...ALL, website: false } }, NOW));
expect(await backfill().step(NOW)).toBe(0);
expect(await queued()).toEqual([]);
expect(await store().backfill()).toEqual({
fields: ['phone', 'email', 'opening_hours', 'description', 'image'],
afterId: id,
});
});
it('PCB-014: after withdrawing everything, switching a field off and on again does not share its history', async () => {
await placeWith();
await agree();
await service().withdrawAll(NOW);
await service().decide(ADMIN, { action: 'fields', fields: { ...ALL, website: false } }, NOW);
await service().decide(ADMIN, { action: 'fields', fields: ALL }, NOW);
expect(await store().backfill()).toBeNull();
expect(await backfill().step(NOW)).toBe(0);
expect(await queued()).toEqual([]);
// A new agreement shares the earlier values again.
await agree();
expect((await store().consent()).withdrawnFields).toBeUndefined();
expect(await backfill().step(NOW)).toBe(1);
});
it('PCB-015: a field withdrawn on its own does not catch up when switched on again, another field still does', async () => {
await agree({ ...ALL, phone: false });
await store().writeBackfill(null);
await service().decide(
ADMIN,
{ action: 'fields', fields: { ...ALL, phone: false, website: false }, withdraw: true },
NOW,
);
expect((await store().consent()).withdrawnFields).toEqual(['website']);
await service().decide(ADMIN, { action: 'fields', fields: ALL }, NOW);
expect(await store().backfill()).toEqual({ fields: ['phone'], afterId: 0 });
});
it('PCB-016: a field withdrawn while its catch-up is under way leaves the catch-up', async () => {
await agree();
await store().writeBackfill({ fields: ['website', 'email'], afterId: 7 });
await service().decide(ADMIN, { action: 'fields', fields: { ...ALL, website: false }, withdraw: true }, NOW);
expect(await store().backfill()).toEqual({ fields: ['email'], afterId: 7 });
await service().decide(
ADMIN,
{ action: 'fields', fields: { ...ALL, website: false, email: false }, withdraw: true },
NOW,
);
expect(await store().backfill()).toBeNull();
});
it('PCB-017: sending that stops being possible while a step reads its page queues nothing, and the catch-up waits', async () => {
await placeWith();
await agree();
betweenReadAndWrite(async () => client.hasKey.mockReturnValue(false));
expect(await backfill().step(NOW)).toBe(0);
expect(await queued()).toEqual([]);
expect(await store().backfill()).toEqual({
fields: ['website', 'phone', 'email', 'opening_hours', 'description', 'image'],
afterId: 0,
});
});
});
@@ -273,6 +273,7 @@ describe('the admin state and the decisions', () => {
expect((await findRows(orm(), PlaceContribOutbox, {})).map((row) => row.field)).toEqual(['website']);
expect(client.withdraw).toHaveBeenCalledWith({ field: 'phone' });
expect(await store().activeFields()).not.toContain('phone');
expect((await store().consent()).withdrawnFields).toEqual(['phone']);
});
it('PCS-017: changing the switches needs a standing agreement', async () => {
@@ -428,6 +429,7 @@ describe('withdrawAll and the log', () => {
body: { error: 'The TREK Places API did not confirm the withdrawal', reason: 'TimeoutError' },
});
expect(await countRows(orm(), PlaceContribOutbox)).toBe(1);
expect((await store().consent()).withdrawnFields).toBeUndefined();
});
it('PCS-042: the log pages newest first and filters by result and field', async () => {
@@ -456,6 +458,17 @@ describe('withdrawAll and the log', () => {
expect((await service().logPage({ page: 1, perPage: 50, result: 'accepted' })).total).toBe(0);
expect((await service().state(ADMIN, NOW)).counts.withheld).toBe(2);
});
it('PCS-043: a confirmed withdrawal keeps the agreement but marks every field withdrawn', async () => {
await agree();
await service().withdrawAll(NOW);
expect(await store().consent()).toMatchObject({
enabled: true,
decision: 'agreed',
includesEarlier: true,
withdrawnFields: ['website', 'phone', 'email', 'opening_hours', 'description', 'image'],
});
});
});
function outboxRow(placeId: number, field: string) {
@@ -137,6 +137,7 @@ async function svc(searchNominatim: OsmClient['searchNominatim']): Promise<Place
await createTestJourneyPhotosRepo(testDb),
await createTestJourneyEntryPhotosRepo(testDb),
await createTestPlacesRepo(testDb),
{ releaseValues: async () => 0 } as never, // StorageReleaseService
),
storageFx.storage,
await accommodationsOver(testDb),

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