feat(collections): import a whole trip's places into a list (#1903)

* feat(collections): preview which of a trip's places a list already holds

The bulk import endpoint has been there since the select-mode copy, but nothing
calls it and it only reports duplicates after the fact. A dialog needs the verdict
up front, so /:id/importable/:tripId returns the trip's places with the duplicate
check the import itself applies, plus whether any day holds the place.

* feat(collections): import a whole trip's places into a list

Two steps: pick a trip, then tick its places. Places already on the list are
greyed out and unpickable, and the ones no day holds start selected — those are
the plans a trip left behind, which is the reason to import at all. Sorted so a
mostly-saved trip reads as the two that are left rather than a wall of grey.

* feat(collections): reach the trip import from the filter row and an empty list

* i18n(collections): translate the trip import into all locales

* fix(collections): stop the label dialog's buttons wrapping mid-word

Three actions in a max-w-sm footer wrapped into two lines each. The assign
button also painted white text on the accent, which disappears on a light one.

* docs(collections): document the trip import
This commit is contained in:
Maurice
2026-08-12 11:54:36 +02:00
committed by GitHub
parent b0fd73681b
commit b842d74a5d
37 changed files with 1286 additions and 11 deletions
@@ -356,6 +356,14 @@ export class CollectionsController {
return this.collections.setCollectionCover(user.id, Number(id), coverUrl, socketId);
}
/** Preview for the bulk trip import: the trip's places plus, per place, the same
* duplicate verdict the import itself applies. Read-only, so the dialog can grey out
* what would be skipped instead of reporting it afterwards. */
@Get(':id/importable/:tripId')
importable(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) {
return this.collections.importablePlaces(user.id, Number(id), Number(tripId));
}
@Get(':id')
get(@CurrentUser() user: User, @Param('id') id: string) {
return this.collections.getCollection(user.id, Number(id));
@@ -21,6 +21,7 @@ import type {
CollectionPlaceUpdateRequest,
CollectionStatus,
CollectionLabel,
CollectionImportablesResponse,
} from '@trek/shared';
import { NotificationsService } from '../notifications/notifications.service';
@@ -567,6 +568,46 @@ export class CollectionsService {
}, socketId);
}
/** The trip's places as offered to the bulk import, each already carrying the verdict
* saveFromTripPlaces would reach for it. Reusing findDuplicateCollectionPlace is the
* whole point: a row the dialog shows as new can never come back as `skipped`, and a
* greyed-out one is exactly a row the import would refuse. Re-deriving that rule in the
* client would be a second copy of it, free to drift.
*
* `scheduled` is false for places no day holds. Those are what a trip leaves behind and
* what this import exists for, so the dialog pre-selects them. */
importablePlaces(userId: number, collectionId: number, tripId: number): CollectionImportablesResponse {
this.assertCanEdit(userId, collectionId);
if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found');
// One row per place: a place can sit on several days, so the day columns resolve to the
// earliest one rather than multiplying the place out across its assignments.
const rows = this.db.all<{
place_id: number; name: string; address: string | null; lat: number | null; lng: number | null;
category_id: number | null; image_url: string | null; day_number: number | null; date: string | null;
}>(`
SELECT p.id AS place_id, p.name, p.address, p.lat, p.lng, p.category_id, p.image_url,
(SELECT MIN(d.day_number) FROM day_assignments da
JOIN days d ON d.id = da.day_id
WHERE da.place_id = p.id AND d.trip_id = p.trip_id) AS day_number,
(SELECT d.date FROM day_assignments da
JOIN days d ON d.id = da.day_id
WHERE da.place_id = p.id AND d.trip_id = p.trip_id
ORDER BY d.day_number ASC LIMIT 1) AS date
FROM places p
WHERE p.trip_id = ?
ORDER BY p.name COLLATE NOCASE
`, tripId);
return {
places: rows.map(r => ({
...r,
already_in_list: this.findDuplicateCollectionPlace(collectionId, { name: r.name, lat: r.lat, lng: r.lng }) != null,
scheduled: r.day_number != null,
})),
};
}
/** Bulk copy of several trip places into a list in one shot — one access check,
* one WS notify (vs saving each place individually). Mirrors saveFromTripPlace's
* field mapping + dedup; skips duplicates unless force. Status starts at 'idea'. */