From 81292d4d2bfe0b28fa0be5a8dafa7aa03bdd32d6 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 22 Sep 2026 12:10:20 +0200 Subject: [PATCH 1/4] fix(server): the booked night settles in one pass, and the sibling paths seat it too The audit over the 4.3.1 release found the seating rule reading a night by one value and its neighbours by another, so two nights on one day never agreed: the migration reported moves that changed nothing, flipped the pair on the next run and dropped the road drawn between them, and in the service every notes edit swapped them. One rule now lives in accommodations/night-seat.ts, measures every night by its own check-in, breaks a tie by the booking made first, and carries the vias; the service, the reseat migration and a trip date change all seat through it, the last of which used to append the night at the end of its day again. An account created by a first SSO login writes the user.register row the password path writes. Tests cover the equal and absent check-ins, the date change, the booking form path and a day that is nothing but its night. --- server/src/db/reseat-booked-nights.ts | 109 ++------ .../accommodations/accommodations.service.ts | 214 +++------------ server/src/nest/accommodations/night-seat.ts | 250 ++++++++++++++++++ server/src/nest/days/days.service.ts | 42 ++- server/src/nest/maps/maps.service.ts | 2 +- server/src/nest/oidc/oidc.controller.ts | 12 + server/src/nest/oidc/oidc.service.ts | 6 +- server/src/nest/places/places.mcp.ts | 4 +- .../nest/roadtrip/roadtrip-planning.mcp.ts | 20 +- server/src/systemNotices/registry.ts | 4 +- server/tests/integration/oidc.test.ts | 8 +- .../tests/unit/db/booked-night-reseat.test.ts | 106 +++++++- .../unit/nest/accommodations.service.test.ts | 67 +++++ server/tests/unit/nest/days.service.test.ts | 85 ++++++ .../tests/unit/nest/maps.autocomplete.test.ts | 62 ++++- .../tests/unit/nest/oidc.controller.test.ts | 19 ++ .../unit/nest/reservations.service.test.ts | 34 +++ .../unit/nest/roadtrip-plan.service.test.ts | 42 ++- .../tests/unit/nest/roadtrip-planning.test.ts | 41 ++- 19 files changed, 826 insertions(+), 301 deletions(-) create mode 100644 server/src/nest/accommodations/night-seat.ts diff --git a/server/src/db/reseat-booked-nights.ts b/server/src/db/reseat-booked-nights.ts index cfe904449..ab4189cb3 100644 --- a/server/src/db/reseat-booked-nights.ts +++ b/server/src/db/reseat-booked-nights.ts @@ -1,11 +1,9 @@ import Database from 'better-sqlite3'; - -type DayRow = { id: number; at: string | null; located: number }; -type Via = { id: number; after_order_index: number; sequence: number }; +import { carryVias, dayStops, locatedIds, seatAmong } from '../nest/accommodations/night-seat'; /** * Seat every booked night where its check-in says, the way a night booked today is - * seated (AccommodationsService.positionForCheckIn). + * seated (night-seat.ts, the rule AccommodationsService applies). * * The stop a booking puts on its check-in day used to go last unless a stop pinned to * a later hour pulled it forward, and the backfill that gave older bookings their stop @@ -16,103 +14,46 @@ type Via = { id: number; after_order_index: number; sequence: number }; * * Only the stops a booking owns move (accommodation_id). A stop the traveller placed * themselves and then booked a night on is theirs, wherever it sits. The drawn roads - * (roadtrip_vias) are pinned to positions among the day's located stops, so they are - * carried along by the rules the service applies (AccommodationsService.reanchorVias): - * a via follows the stop it was drawn after, a via behind a stop that is last stays - * with it, and one left without a leg goes. + * (roadtrip_vias) are pinned to positions among the day's located stops and are + * carried along by the rules the service applies (carryVias): a via follows the stop + * it was drawn after, a via behind a stop that is last stays with it, and one left + * without a leg goes. * - * Runs inside the migration's transaction. Re-runnable: a night already in its seat is - * passed over. Returns how many nights moved. + * Runs inside the migration's transaction. Re-runnable: the nights of a day are taken + * in the order the rule seats them (a night without a check-in first, then by + * check-in, ties by booking), so one pass leaves every night in its seat and a second + * pass moves nothing. Returns how many nights moved. */ export function reseatBookedNights(db: Database.Database): number { const nights = db.prepare(` - SELECT da.id, da.day_id, a.check_in + SELECT da.id, da.day_id, a.id AS accommodation_id, a.check_in FROM day_assignments da JOIN day_accommodations a ON a.id = da.accommodation_id - ORDER BY da.day_id, da.order_index, da.id - `).all() as Array<{ id: number; day_id: number; check_in: string | null }>; - // Another night on the same day counts by its check-in, so two bookings on one - // day settle by the clock instead of by whichever was seated last. - const dayRows = db.prepare(` - SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at, - (p.lat IS NOT NULL AND p.lng IS NOT NULL) AS located - FROM day_assignments da JOIN places p ON p.id = da.place_id - LEFT JOIN day_accommodations other ON other.id = da.accommodation_id - WHERE da.day_id = ? - ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC - `); + ORDER BY da.day_id, a.check_in IS NOT NULL, a.check_in, a.id + `).all() as Array<{ id: number; day_id: number; accommodation_id: number; check_in: string | null }>; const setIndex = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?'); - const viasOf = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?'); - const dropVia = db.prepare('DELETE FROM roadtrip_vias WHERE id = ?'); - const moveVia = db.prepare('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ?'); - const setSequence = db.prepare('UPDATE roadtrip_vias SET sequence = ? WHERE id = ?'); let seated = 0; + const roads = { moved: 0, removed: 0 }; for (const night of nights) { - const rows = dayRows.all(night.day_id) as DayRow[]; + const rows = dayStops(db, night.day_id); const own = rows.findIndex((row) => row.id === night.id); if (own < 0) continue; const others = rows.filter((row) => row.id !== night.id); - let seat = 0; - if (night.check_in) { - others.forEach((row, i) => { - if (row.at !== null && row.at <= night.check_in!) seat = i + 1; - }); - } - const next = [...others.slice(0, seat), rows[own]!, ...others.slice(seat)]; - if (next.every((row, i) => row.id === rows[i]!.id)) continue; + const seat = seatAmong(others, { id: night.accommodation_id, check_in: night.check_in }); + const next = [...others.slice(0, seat), rows[own], ...others.slice(seat)]; + if (next.every((row, i) => row.id === rows[i].id)) continue; - const previousLocated = rows.filter((row) => row.located).map((row) => row.id); next.forEach((row, i) => setIndex.run(i, row.id)); seated += 1; - - const nextLocated = next.filter((row) => row.located).map((row) => row.id); - const vias = viasOf.all(night.day_id) as Via[]; - if (!vias.length) continue; - const previousLast = previousLocated.length - 1; - const nextLast = nextLocated.length - 1; - const lastStayed = previousLast >= 0 && previousLocated[previousLast] === nextLocated[nextLast]; - const landed = new Set(); - const kept: Array = []; - for (const via of vias) { - const leg = lastStayed && via.after_order_index === previousLast - ? nextLast - : legAfter(via.after_order_index, previousLocated, nextLocated); - if (leg === null) { - dropVia.run(via.id); - continue; - } - if (leg !== via.after_order_index) { - moveVia.run(leg, via.id); - landed.add(via.id); - } - kept.push({ ...via, was: via.after_order_index, after_order_index: leg }); - } - // Two legs that merged carry two sequence series side by side. Renumbered the way - // the service does it: the earlier leg's points first, then by their old sequence, - // then by id. Only a leg that received a via is touched. - const byLeg = new Map>(); - for (const via of kept) byLeg.set(via.after_order_index, [...(byLeg.get(via.after_order_index) ?? []), via]); - for (const onLeg of byLeg.values()) { - if (!onLeg.some((via) => landed.has(via.id))) continue; - onLeg.sort((a, b) => a.was - b.was || a.sequence - b.sequence || a.id - b.id); - onLeg.forEach((via, i) => { - if (via.sequence !== i) setSequence.run(i, via.id); - }); + const carried = carryVias(db, night.day_id, locatedIds(rows), locatedIds(next)); + if (carried) { + roads.moved += carried.moved; + roads.removed += carried.removed; } } + if (roads.moved || roads.removed) { + console.log(`[DB] Re-pinned ${roads.moved} drawn road(s) behind the reseated nights, dropped ${roads.removed} left without a leg`); + } return seated; } - -/** - * The leg a via pinned behind the n-th stop of the old order is on in the new one - * (AccommodationsService.legAfter). Null when no leg is left for it. - */ -function legAfter(index: number, previousIds: number[], nextIds: number[]): number | null { - if (index > previousIds.length - 1) return null; - let at = index; - while (at >= 0 && !nextIds.includes(previousIds[at]!)) at -= 1; - if (at < 0) return null; - const next = nextIds.indexOf(previousIds[at]!); - return next >= nextIds.length - 1 ? null : next; -} diff --git a/server/src/nest/accommodations/accommodations.service.ts b/server/src/nest/accommodations/accommodations.service.ts index a7403fe7d..710b8547c 100644 --- a/server/src/nest/accommodations/accommodations.service.ts +++ b/server/src/nest/accommodations/accommodations.service.ts @@ -4,6 +4,7 @@ import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService, type PlaceWithTags, type TripAccess } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; import { AssignmentsService } from '../assignments/assignments.service'; +import { carryVias, dayStops, locatedStopIds, reseatOwnStop, seatHolds, seatIndex, type Night } from './night-seat'; import type { User } from '../../types'; type Trip = TripAccess; @@ -13,9 +14,6 @@ type MirroredAssignment = ReturnType; /** One day's drawn roads after a write re-pinned them, as the road trip broadcasts them. */ type DayVias = { dayId: number; vias: RoadtripVia[] }; -/** A via as the re-pinning reads it: where it is pinned and its place on that leg. */ -type PinnedVia = { id: number; after_order_index: number; sequence: number }; - /** How a surface sends the mirror's events; see announceMirror. */ export type MirrorSender = (event: E, payload: TrekWsPayload) => void; @@ -271,69 +269,6 @@ export class AccommodationsService { return errors; } - /** - * Put the booking's check-in day on the map. - * - * Only the check-in day gets a stop, even for a fortnight's stay: that is the - * day you drive there, and it is exactly what the road-trip side writes for a - * night it books itself. The later nights ride on the stay row, which is where - * the rail reads check-out from. - * - * Runs inside the caller's transaction. - */ - /** - * Where in the day the booked night belongs. - * - * First. The hotel is where the day is based: the traveller checks in and the stops - * they placed without an hour follow from there. Only a stop with a clock of its - * own can stand ahead of it, and only when that clock is at or before the check-in: - * a stop pinned to eight with a check-in at ten puts the night second, one pinned - * to the afternoon does not. It used to go last unless something pinned later - * pulled it forward, and a night booked for ten in the morning sat behind a whole - * day of unpinned stops, reached at a quarter past twelve. - */ - private positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): number { - if (!checkIn) return 0; - // excludeId leaves the row being re-seated out of the chain it is measured - // against. Without it a night parked at the end of the day can find itself. - // Another booked night on the day counts by its check-in, so two bookings on - // one day settle by the clock. - const rows = this.db.all<{ order_index: number; at: string | null }>(` - SELECT da.order_index, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at - FROM day_assignments da JOIN places p ON p.id = da.place_id - LEFT JOIN day_accommodations other ON other.id = da.accommodation_id - WHERE da.day_id = ? AND da.id != ? ORDER BY da.order_index - `, dayId, excludeId ?? -1); - let seat = 0; - for (const row of rows) if (row.at !== null && row.at <= checkIn) seat = row.order_index + 1; - return seat; - } - - /** - * Whether the night sits somewhere its check-in allows, on an edit that did not - * touch the check-in. - * - * Read off the chain rather than recomputed, because the index a fresh insert would - * get is not the index this row already occupies. Two ways to be wrong: something - * with a later hour ahead of it, or something with an earlier one behind it. Stops - * without an hour are passed over in both directions: the night may have been - * dragged past them on purpose, and a change of notes is no reason to undo that. - */ - private seatedByCheckIn(dayId: number, ownId: number, checkIn: string | null | undefined): boolean { - if (!checkIn) return true; - const rows = this.db.all<{ id: number; at: string | null }>(` - SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at - FROM day_assignments da JOIN places p ON p.id = da.place_id - LEFT JOIN day_accommodations other ON other.id = da.accommodation_id - WHERE da.day_id = ? ORDER BY da.order_index - `, dayId); - const own = rows.findIndex(row => row.id === ownId); - if (own < 0) return true; - const laterAhead = rows.slice(0, own).some(row => row.at !== null && row.at > checkIn); - const earlierBehind = rows.slice(own + 1).some(row => row.at !== null && row.at <= checkIn); - return !laterAhead && !earlierBehind; - } - /** * Type the place as lodging, unless the traveller already typed it themselves. * @@ -358,58 +293,23 @@ export class AccommodationsService { * coordinates are never routed and so never counted. */ private stopOrders(dayIds: number[]): Map { - return new Map([...new Set(dayIds)].map((dayId): [number, number[]] => [dayId, this.locatedStopIds(dayId)])); - } - - private locatedStopIds(dayId: number): number[] { - return this.db.all<{ id: number }>(` - SELECT da.id FROM day_assignments da JOIN places p ON p.id = da.place_id - WHERE da.day_id = ? AND p.lat IS NOT NULL AND p.lng IS NOT NULL - ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC - `, dayId).map(row => row.id); + return new Map([...new Set(dayIds)].map((dayId): [number, number[]] => [dayId, locatedStopIds(this.db, dayId)])); } /** * Keep every drawn road behind the stop it was drawn after, now that this write - * has seated, moved or taken out a stop on these days. + * has seated, moved or taken out a stop on these days (carryVias has the rules). * * A night seated by its check-in ahead of the afternoon renumbers everything * behind it, and a via pinned to position one would otherwise bend the drive * into the hotel instead of the leg it was drawn on. Persisted and visible to - * everyone, so it is put right where the stop moved, by the rules the planner - * applies when a stop is dragged or taken out: a via follows its stop, a stop - * that left the day hands its road to the stop before it, and a stop that is - * last has no leg to keep a via on. + * everyone, so it is put right where the stop moved and reported on the mirror. * * Runs inside the caller's transaction. */ private reanchorVias(mirror: AccommodationMirror, before: Map): void { for (const [dayId, previousIds] of before) { - const nextIds = this.locatedStopIds(dayId); - if (previousIds.length === nextIds.length && previousIds.every((id, i) => id === nextIds[i])) continue; - const vias = this.db.all('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?', dayId); - if (!vias.length) continue; - - // A via behind the day's last stop bends the drive into the next day. It stays - // with that stop when the stop is still last, whatever its number is now: - // measured as a leg it would be dropped, because a last stop has no leg. - const previousLast = previousIds.length - 1; - const nextLast = nextIds.length - 1; - const lastStayed = previousLast >= 0 && previousIds[previousLast] === nextIds[nextLast]; - const remove: number[] = []; - const moved: { id: number; after_order_index: number }[] = []; - for (const via of vias) { - const next = lastStayed && via.after_order_index === previousLast - ? nextLast - : this.legAfter(via.after_order_index, previousIds, nextIds); - if (next === null) remove.push(via.id); - else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next }); - } - if (!remove.length && !moved.length) continue; - - for (const viaId of remove) this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', viaId, dayId); - for (const via of moved) this.db.run('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?', via.after_order_index, via.id, dayId); - this.renumberMergedLegs(dayId, vias.filter(via => !remove.includes(via.id)), moved); + if (!carryVias(this.db, dayId, previousIds, locatedStopIds(this.db, dayId))) continue; this.noteVias(mirror, { dayId, vias: this.db.all( @@ -418,47 +318,6 @@ export class AccommodationsService { } } - /** - * The leg a via pinned behind the n-th stop of the old order is on in the new one. - * - * A stop this write took off the day hands its road to the stop before it: the - * leg it was drawn on merges into the one ahead, the way taking a stop out of the - * drive merges them (`RoadtripService.reanchor`). Null when no leg is left for it, - * because the stop it follows is the last one now, nothing ahead of it survived, - * or it was pinned past the day's end to begin with. - */ - private legAfter(index: number, previousIds: number[], nextIds: number[]): number | null { - if (index > previousIds.length - 1) return null; - let at = index; - while (at >= 0 && !nextIds.includes(previousIds[at])) at -= 1; - if (at < 0) return null; - const next = nextIds.indexOf(previousIds[at]); - return next >= nextIds.length - 1 ? null : next; - } - - /** - * Two legs that merged carry two sequence series side by side, and everything - * that draws the route orders by sequence: the drive would run through the first - * leg's point, the second leg's, and back. Renumbered the way - * `RoadtripService.reanchor` does it, the earlier leg's points first, then by - * their old sequence, then by id. Only a leg that received a via is touched. - */ - private renumberMergedLegs(dayId: number, kept: PinnedVia[], moved: { id: number; after_order_index: number }[]): void { - const landed = new Map(moved.map(via => [via.id, via.after_order_index])); - const byLeg = new Map(); - for (const via of kept) { - const leg = landed.get(via.id) ?? via.after_order_index; - byLeg.set(leg, [...(byLeg.get(leg) ?? []), via]); - } - for (const onLeg of byLeg.values()) { - if (!onLeg.some(via => landed.has(via.id))) continue; - onLeg.sort((a, b) => a.after_order_index - b.after_order_index || a.sequence - b.sequence || a.id - b.id); - onLeg.forEach((via, index) => { - if (via.sequence !== index) this.db.run('UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?', index, via.id, dayId); - }); - } - } - /** One entry per day: a write that takes a stop off a day and puts one back on * the same day reports the state it left behind, not both steps. */ private noteVias(mirror: AccommodationMirror, day: DayVias): void { @@ -483,39 +342,25 @@ export class AccommodationsService { stop: { id: number; day_id: number; order_index: number }, placeId: number, dayId: number, - checkIn?: string | null, + night: Night, ): MirroredAssignment | null { if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ? AND id != ?', dayId, placeId, stop.id)) { return null; } - - // Close the gap the row leaves behind. A no-op when it is not changing days, - // because it is put back into that same numbering two statements down. - this.db.run( - 'UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?', - stop.day_id, stop.order_index, - ); - - // Park it at the end of the target day first, then seat it by the check-in the - // same way a fresh insert would. Two steps, because the index it should get is - // read off the chain it is not part of yet. - const max = this.db.get<{ max: number | null }>( - 'SELECT MAX(order_index) AS max FROM day_assignments WHERE day_id = ? AND id != ?', dayId, stop.id)!; - const end = (max.max !== null ? max.max : -1) + 1; - this.db.run('UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?', dayId, placeId, end, stop.id); - - const seat = this.positionForCheckIn(dayId, checkIn, stop.id); - if (seat < end) { - this.db.run( - 'UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? AND id != ?', - dayId, seat, stop.id, - ); - this.db.run('UPDATE day_assignments SET order_index = ? WHERE id = ?', seat, stop.id); - } - + reseatOwnStop(this.db, stop, placeId, dayId, night); return this.assignments.getAssignmentWithPlace(stop.id); } + /** + * Put the booking's check-in day on the map. + * + * Only the check-in day gets a stop, even for a fortnight's stay: that is the + * day you drive there, and it is exactly what the road-trip side writes for a + * night it books itself. The later nights ride on the stay row, which is where + * the rail reads check-out from. + * + * Runs inside the caller's transaction. + */ private mirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror { const mirror = noMirror(); // A stay can outlive its place (place_id is ON DELETE SET NULL) and the booking @@ -531,8 +376,8 @@ export class AccommodationsService { if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, placeId)) return mirror; const before = this.stopOrders([dayId]); - // Through AssignmentsService, seated where the check-in says (see - // positionForCheckIn), with everything behind it moved up one. + // Through AssignmentsService, seated where the check-in says (night-seat.ts), + // with everything behind it moved up one. // // The booking id goes in with the INSERT, not as an UPDATE afterwards: what this // returns is the row the answer hands the client, and stamping the id on later @@ -541,7 +386,7 @@ export class AccommodationsService { // time until the next reload. mirror.created = this.assignments.createAssignment(dayId, placeId, null, { accommodationId, - orderIndex: this.positionForCheckIn(dayId, checkIn), + orderIndex: seatIndex(this.db, dayId, { id: accommodationId, check_in: checkIn }), }); this.reanchorVias(mirror, before); return mirror; @@ -610,14 +455,17 @@ export class AccommodationsService { ): AccommodationMirror { const own = this.ownStops(accommodationId); if (own.length === 0) return noMirror(); + const night: Night = { id: accommodationId, check_in: checkIn }; if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) { - // Same place, same day. A check-in given a new hour seats the night afresh, the - // way booking it with that hour would have; any other edit leaves a stop where - // it is unless the clocks around it say it is in the wrong place, so a night the - // traveller dragged somewhere stays there through a change of notes. - const settled = opts.checkInChanged - ? this.positionForCheckIn(dayId, checkIn, own[0].id) === own[0].order_index - : this.seatedByCheckIn(dayId, own[0].id, checkIn); + // Same place, same day. A stop already where a fresh seat would put it stays, + // whatever the edit: relocating it would land it in the same place and report + // a move that moved nothing. A check-in given a new hour seats the night afresh + // otherwise, the way booking it with that hour would have; any other edit + // leaves the stop alone unless the clocks around it say it is in the wrong + // place, so a night the traveller dragged somewhere stays there through a + // change of notes. + const settled = seatIndex(this.db, dayId, night, own[0].id) === own[0].order_index + || (!opts.checkInChanged && seatHolds(dayStops(this.db, dayId), own[0].id, checkIn)); if (settled) return noMirror(); } @@ -629,7 +477,7 @@ export class AccommodationsService { // on its id, all of which a DELETE takes with it. if (own.length === 1 && placeId) { const before = this.stopOrders([own[0].day_id, dayId]); - const moved = this.relocateOwnStop(own[0], placeId, dayId, checkIn); + const moved = this.relocateOwnStop(own[0], placeId, dayId, night); if (moved) { mirror.moved = { assignment: moved, oldDayId: own[0].day_id }; mirror.stamped = this.stampLodging(placeId); diff --git a/server/src/nest/accommodations/night-seat.ts b/server/src/nest/accommodations/night-seat.ts new file mode 100644 index 000000000..829326bfd --- /dev/null +++ b/server/src/nest/accommodations/night-seat.ts @@ -0,0 +1,250 @@ +import type Database from 'better-sqlite3'; + +/** + * Where a booked night sits in its day, and how the drawn roads follow when a stop + * moves. + * + * One rule, applied by everything that seats a night: AccommodationsService when a + * night is booked or edited, DaysService when a trip's dates move and carry the + * night to another day row, and the migration that brought the trips planned under + * the old rule along. Written over the connection's `prepare` so a raw better-sqlite3 + * handle fits as well as DatabaseService. Everything here runs inside the caller's + * transaction. + */ +export interface SeatConnection { + prepare(sql: string): Database.Statement; +} + +/** A stop of one day as the rule sees it: the clock it is measured by (dayStops), the + * booking that owns it, and whether the router counts it. */ +export interface SeatRow { + id: number; + order_index: number; + at: string | null; + night_id: number | null; + located: number; +} + +/** The booking whose stop is being seated. */ +export interface Night { + id: number; + check_in: string | null | undefined; +} + +/** A via as the re-pinning reads it: where it is pinned and its place on that leg. */ +interface PinnedVia { + id: number; + after_order_index: number; + sequence: number; +} + +/** + * A day's stops in order, each with the hour it is measured by: the visit's own, else + * the place's. + * + * A stop a booking owns is measured by that booking's check-in instead, and by nothing + * else. That is the value the night seats itself by, so it has to be the value its + * peers see as well: read the same row as an hour pinned on the stop and the two nights + * of a day would answer the question differently depending on which of them is asking, + * and a pass that seats them both would never settle. + */ +export function dayStops(db: SeatConnection, dayId: number): SeatRow[] { + return db.prepare(` + SELECT da.id, da.order_index, + CASE WHEN other.id IS NULL THEN COALESCE(da.assignment_time, p.place_time) ELSE other.check_in END AS at, + other.id AS night_id, (p.lat IS NOT NULL AND p.lng IS NOT NULL) AS located + FROM day_assignments da JOIN places p ON p.id = da.place_id + LEFT JOIN day_accommodations other ON other.id = da.accommodation_id + WHERE da.day_id = ? + ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC + `).all(dayId) as SeatRow[]; +} + +/** The stops the router counts, in day order: the positions the vias are pinned to. */ +export function locatedIds(rows: readonly SeatRow[]): number[] { + return rows.filter(row => row.located).map(row => row.id); +} + +export function locatedStopIds(db: SeatConnection, dayId: number): number[] { + return locatedIds(dayStops(db, dayId)); +} + +/** + * Whether `row` stands ahead of the night on its day. + * + * The night leads its day: the hotel is where the day is based, and the stops placed + * without an hour follow from there. Only a stop with a clock of its own stands ahead + * of it, and only when that clock is at or before the check-in: a stop pinned to eight + * with a check-in at ten puts the night second, one pinned to the afternoon does not. + * + * Two nights on one day settle by their check-ins. When those agree, or both are + * missing, the earlier booking leads; without that each would count the other as + * ahead and the pair would trade places on every pass. A night without a check-in + * leads the day outright, ahead of one with a clock. + * + * Among the nights of a day that is a strict order: the ones without a check-in by + * booking, then the rest by check-in and booking. What each of them counts as ahead + * grows along that order, which is what lets a caller seat a whole day's nights in + * one pass and find them all settled (reseat-booked-nights.ts). + */ +export function standsAhead(row: SeatRow, night: Night): boolean { + const earlierBooking = row.night_id !== null && row.night_id < night.id; + if (!night.check_in) return row.at === null && earlierBooking; + if (row.at === null) return row.night_id !== null; + if (row.at === night.check_in) return row.night_id === null || earlierBooking; + return row.at < night.check_in; +} + +/** The position among `others` (the day's stops without the night's own) the night + * takes: right behind the last row that stands ahead of it, else first. */ +export function seatAmong(others: readonly SeatRow[], night: Night): number { + let seat = 0; + others.forEach((row, i) => { + if (standsAhead(row, night)) seat = i + 1; + }); + return seat; +} + +/** + * The order_index a fresh insert of the night gets, with everything from there on + * moved down (AssignmentsService.createAssignment). `excludeId` leaves the night's + * own row out of the chain it is measured against: without it a night parked at + * the end of the day can find itself. + */ +export function seatIndex(db: SeatConnection, dayId: number, night: Night, excludeId?: number): number { + const others = dayStops(db, dayId).filter(row => row.id !== excludeId); + const seat = seatAmong(others, night); + return seat === 0 ? 0 : others[seat - 1].order_index + 1; +} + +/** + * Whether the night already sits somewhere its check-in allows, on an edit that did + * not touch the check-in. + * + * Read off the chain rather than recomputed, because the index a fresh insert would + * get is not the index this row occupies. Two ways to be wrong: something with a + * later hour ahead of it, or something with an earlier one behind it. Stops without + * an hour are passed over in both directions, and so is another night with the same + * check-in: the two may have been dragged into this order on purpose, and a change + * of notes is no reason to undo that. + */ +export function seatHolds(rows: readonly SeatRow[], ownId: number, checkIn: string | null | undefined): boolean { + if (!checkIn) return true; + const own = rows.findIndex(row => row.id === ownId); + if (own < 0) return true; + const laterAhead = rows.slice(0, own).some(row => row.at !== null && row.at > checkIn); + const earlierBehind = rows.slice(own + 1).some(row => + row.at !== null && (row.night_id === null ? row.at <= checkIn : row.at < checkIn)); + return !laterAhead && !earlierBehind; +} + +/** + * Carry a night's own stop to `dayId` in place, seated where its check-in says. + * + * The gap it leaves on the day it came from is closed. Then it is parked at the end + * of the target day and seated the way a fresh insert would be: two steps, because + * the index it should get is read off a chain it is not part of yet. + */ +export function reseatOwnStop( + db: SeatConnection, + stop: { id: number; day_id: number; order_index: number }, + placeId: number, + dayId: number, + night: Night, +): void { + db.prepare('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?') + .run(stop.day_id, stop.order_index); + const max = db.prepare('SELECT MAX(order_index) AS max FROM day_assignments WHERE day_id = ? AND id != ?') + .get(dayId, stop.id) as { max: number | null }; + const end = (max.max !== null ? max.max : -1) + 1; + db.prepare('UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?').run(dayId, placeId, end, stop.id); + + const seat = seatIndex(db, dayId, night, stop.id); + if (seat < end) { + db.prepare('UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? AND id != ?') + .run(dayId, seat, stop.id); + db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?').run(seat, stop.id); + } +} + +/** + * Keep every drawn road behind the stop it was drawn after, now that a write has + * seated, moved or taken out a stop on this day. `previousIds` and `nextIds` are the + * located stops in order before and after: the index space the vias are pinned to. + * + * The rules the planner applies when a stop is dragged or taken out: a via follows + * its stop, a stop that left the day hands its road to the stop before it, and a + * stop that is last has no leg to keep a via on. A via behind the day's last stop + * bends the drive into the next day and stays with that stop while it is still last, + * whatever its number is now; measured as a leg it would be dropped. + * + * Returns what changed, or null when nothing did. + */ +export function carryVias(db: SeatConnection, dayId: number, previousIds: number[], nextIds: number[]): { moved: number; removed: number } | null { + if (previousIds.length === nextIds.length && previousIds.every((id, i) => id === nextIds[i])) return null; + const vias = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?').all(dayId) as PinnedVia[]; + if (!vias.length) return null; + + const previousLast = previousIds.length - 1; + const nextLast = nextIds.length - 1; + const lastStayed = previousLast >= 0 && previousIds[previousLast] === nextIds[nextLast]; + const remove: number[] = []; + const moved: { id: number; after_order_index: number }[] = []; + for (const via of vias) { + const next = lastStayed && via.after_order_index === previousLast + ? nextLast + : legAfter(via.after_order_index, previousIds, nextIds); + if (next === null) remove.push(via.id); + else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next }); + } + if (!remove.length && !moved.length) return null; + + for (const viaId of remove) db.prepare('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?').run(viaId, dayId); + for (const via of moved) { + db.prepare('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?').run(via.after_order_index, via.id, dayId); + } + renumberMergedLegs(db, dayId, vias.filter(via => !remove.includes(via.id)), moved); + return { moved: moved.length, removed: remove.length }; +} + +/** + * The leg a via pinned behind the n-th stop of the old order is on in the new one. + * + * A stop the write took off the day hands its road to the stop before it: the leg + * it was drawn on merges into the one ahead, the way taking a stop out of the drive + * merges them (`RoadtripService.reanchor`). Null when no leg is left for it, because + * the stop it follows is the last one now, nothing ahead of it survived, or it was + * pinned past the day's end to begin with. In a pure reorder every stop survives, so + * nothing merges and each via lands on the leg of the very stop it was drawn after. + */ +function legAfter(index: number, previousIds: number[], nextIds: number[]): number | null { + if (index > previousIds.length - 1) return null; + let at = index; + while (at >= 0 && !nextIds.includes(previousIds[at])) at -= 1; + if (at < 0) return null; + const next = nextIds.indexOf(previousIds[at]); + return next >= nextIds.length - 1 ? null : next; +} + +/** + * Two legs that merged carry two sequence series side by side, and everything that + * draws the route orders by sequence: the drive would run through the first leg's + * point, the second leg's, and back. Renumbered the way `RoadtripService.reanchor` + * does it, the earlier leg's points first, then by their old sequence, then by id. + * Only a leg that received a via is touched. + */ +function renumberMergedLegs(db: SeatConnection, dayId: number, kept: PinnedVia[], moved: { id: number; after_order_index: number }[]): void { + const landed = new Map(moved.map(via => [via.id, via.after_order_index])); + const byLeg = new Map(); + for (const via of kept) { + const leg = landed.get(via.id) ?? via.after_order_index; + byLeg.set(leg, [...(byLeg.get(leg) ?? []), via]); + } + for (const onLeg of byLeg.values()) { + if (!onLeg.some(via => landed.has(via.id))) continue; + onLeg.sort((a, b) => a.after_order_index - b.after_order_index || a.sequence - b.sequence || a.id - b.id); + onLeg.forEach((via, index) => { + if (via.sequence !== index) db.prepare('UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?').run(index, via.id, dayId); + }); + } +} diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index e26398746..d9c7cdf04 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -4,6 +4,7 @@ import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService, type TripAccess } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; import { QueryHelpersService } from '../query-helpers/query-helpers.service'; +import { carryVias, locatedStopIds, reseatOwnStop } from '../accommodations/night-seat'; import { formatAssignmentWithPlace } from '../common/rowShape'; import type { AssignmentRow, Day, DayNote, User } from '../../types'; @@ -354,8 +355,8 @@ export class DaysService { tripId: string | number, prevDateByDayId: Map, ): void { - const stays = this.db.all<{ id: number; start_day_id: number; end_day_id: number }>( - 'SELECT id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?', + const stays = this.db.all<{ id: number; start_day_id: number; end_day_id: number; check_in: string | null }>( + 'SELECT id, start_day_id, end_day_id, check_in FROM day_accommodations WHERE trip_id = ?', tripId ); if (stays.length === 0) return; @@ -368,16 +369,6 @@ export class DaysService { ELSE :date || SUBSTR(reservation_time, 11) END WHERE accommodation_id = :accId AND type = 'hotel' `); - // The day stop a booking wrote moves with it, the way its linked booking does. - // Left behind it would sit on a day the traveller no longer sleeps there, with - // nothing on screen to say why. Re-indexed to the end of the target day, because - // its old position belonged to a day it is leaving. - const moveStayStop = this.db.prepare(` - UPDATE day_assignments - SET day_id = :dayId, - order_index = COALESCE((SELECT MAX(order_index) FROM day_assignments WHERE day_id = :dayId), -1) + 1 - WHERE accommodation_id = :accId - `); for (const stay of stays) { const oldStartDate = prevDateByDayId.get(stay.start_day_id); @@ -388,7 +379,7 @@ export class DaysService { if (newStart && newEnd && newStart.day_number <= newEnd.day_number && (newStart.id !== stay.start_day_id || newEnd.id !== stay.end_day_id)) { updateStay.run(newStart.id, newEnd.id, stay.id); - moveStayStop.run({ dayId: newStart.id, accId: stay.id }); + if (newStart.id !== stay.start_day_id) this.carryStayStop(stay, newStart.id); stay.start_day_id = newStart.id; } } @@ -401,6 +392,31 @@ export class DaysService { } } + /** + * The day stop a booking wrote moves with it, the way its linked booking does. + * Left behind it would sit on a day the traveller no longer sleeps there, with + * nothing on screen to say why. Seated on its new day by its check-in like a + * night booked there (night-seat.ts), with the drawn roads of both days re-pinned + * behind the stops they were drawn after. A day that already holds the place under + * a stop of the traveller's keeps that one, and the booking rides along with it. + */ + private carryStayStop(stay: { id: number; check_in: string | null }, dayId: number): void { + const own = this.db.all<{ id: number; day_id: number; place_id: number; order_index: number }>( + 'SELECT id, day_id, place_id, order_index FROM day_assignments WHERE accommodation_id = ? AND day_id != ?', stay.id, dayId + ); + for (const stop of own) { + const before = [stop.day_id, dayId].map((id): [number, number[]] => [id, locatedStopIds(this.db, id)]); + if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, stop.place_id)) { + this.db.run('DELETE FROM day_assignments WHERE id = ?', stop.id); + this.db.run('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?', + stop.day_id, stop.order_index); + } else { + reseatOwnStop(this.db, stop, stop.place_id, dayId, { id: stay.id, check_in: stay.check_in }); + } + for (const [id, previousIds] of before) carryVias(this.db, id, previousIds, locatedStopIds(this.db, id)); + } + } + /** * Reorder whole days. `orderedIds` is the desired full sequence of this trip's * day ids (a permutation of the current ids). diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index edf5de8f3..1b4b63fbe 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -765,7 +765,7 @@ export class MapsService { return this.isSettingDisabled('places_photos_enabled'); } - // ── Controller-facing surface (unchanged signatures) ─────────────────────── + // ── Controller-facing surface ────────────────────────────────────────────── search( userId: number, diff --git a/server/src/nest/oidc/oidc.controller.ts b/server/src/nest/oidc/oidc.controller.ts index 873c35e13..3a2ee06ea 100644 --- a/server/src/nest/oidc/oidc.controller.ts +++ b/server/src/nest/oidc/oidc.controller.ts @@ -167,6 +167,18 @@ export class OidcController { const result = this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken); if ('error' in result) return f('/login?oidc_error=' + result.error); + if (result.created) { + // An account the callback just made is a registration, the way a password + // signup is (auth-public.controller): the same row, plus the way in. Without + // it an admin reading user.register for who got an account never sees the + // SSO ones, and the log looks complete while it is not. + this.audit.writeAudit({ + userId: result.user.id, + action: 'user.register', + ip: getClientIp(req), + details: { username: result.user.username, email: result.user.email, role: result.user.role, method: 'oidc' }, + }); + } if (result.roleChange) { // The claim mapping changing someone's privileges is a security event, and // the row is written here because this is where the client IP is. The claim diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index e90f9268e..0183c54bb 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -611,7 +611,7 @@ export class OidcService implements OnModuleDestroy { userInfo: OidcUserInfo, config: OidcConfig, inviteToken?: string, - ): { user: User; roleChange?: OidcRoleChange } | { error: string } { + ): { user: User; roleChange?: OidcRoleChange; created?: true } | { error: string } { // Defense-in-depth for direct callers — the controller redirects on a // missing email before it ever calls this; the same code flows through its // `oidc_error=' + result.error` pass-through if reached here. @@ -752,7 +752,9 @@ export class OidcService implements OnModuleDestroy { // is_guest, created_at, …) instead of a hand-built partial — same shape // the existing-user branch returns. user = this.db.prepare('SELECT * FROM users WHERE id = ?').get(Number(result.lastInsertRowid)) as User; - return { user }; + // The one branch that makes an account. The caller writes the registration row + // off this, so it has to be set here and nowhere else. + return { user, created: true }; } catch (err) { if (err === inviteRaceError) { console.warn(`[OIDC] Invite token ${inviteToken?.slice(0, 8)}... exhausted — concurrent callback won the last slot`); diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 2f9649331..e28c0cd6c 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -301,12 +301,12 @@ export class PlacesMcp { @Tool({ name: 'search_place', - description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap only: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.', + description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap, or Google Places alone with provider "google" and whenever the admin switch "Search with Google only" is on: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.', inputSchema: { query: z.string().min(1).max(500).describe('Place name or address to search for'), locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'), lang: z.string().max(35).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja". Defaults to English'), - provider: mapsSearchRequestSchema.shape.provider.describe('"google" sends this one search to Google Places alone instead of the TREK index and OpenStreetMap, the same as the "search Google instead" link under the results in the app. Ignored when the instance has no Google key'), + provider: mapsSearchRequestSchema.shape.provider.describe('"google" sends this one search to Google Places alone instead of the TREK index and OpenStreetMap, the same as the "search Google instead" link under the results in the app. Ignored unless Google holds the keyed slot: an instance without a Google key, or one whose admin picked Amap or OpenStreetMap as the places provider, answers from the TREK index and OpenStreetMap as usual'), }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'places', mode: 'read' }, diff --git a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts index fda4b027e..d517bef2c 100644 --- a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts @@ -10,7 +10,15 @@ import { type RoadtripPlanRequest, type RoadtripCorridorRequest, } from '@trek/shared'; -import { corridorTiles, projectOntoRoute, simplifyLine } from '@trek/shared/roadtrip'; +import { + corridorTiles, + drivenPieces, + inRiddenRange, + projectOntoRoute, + rideGaps, + riddenRanges, + simplifyLine, +} from '@trek/shared/roadtrip'; import { answeringRefusals } from './roadtrip-mcp.helpers'; import { z } from 'zod'; @@ -84,7 +92,7 @@ export class RoadtripPlanningMcp { @Tool({ name: 'search_roadtrip_corridor', description: - 'Search for fuel, charging, rest areas, campsites, food, sights or accommodation along a calculated roadtrip day, without a browser. Uses the routed road, not a straight line. Returns distance along the route and distance from it. Search rectangles are paged in batches of six; continue with nextOffset until null for full coverage. Optional name, socket family and minimum power filter the matches; missing charging details mean unknown. Does not add stops. Add a chosen place and assign it to the appropriate stored day, preserving any via anchors. Routing failures refuse the corridor search rather than searching an invented line.', + 'Search for fuel, charging, rest areas, campsites, food, sights or accommodation along a calculated roadtrip day, without a browser. Uses the routed road, not a straight line, and leaves out the ride between the two terminals of a flight, train, ferry, cruise or bus booking: no rectangle is searched under it and nothing found there is a hit. Returns distance along the route and distance from it. Search rectangles are paged in batches of six; continue with nextOffset until null for full coverage. Optional name, socket family and minimum power filter the matches; missing charging details mean unknown. Does not add stops. Add a chosen place and assign it to the appropriate stored day, preserving any via anchors. Routing failures refuse the corridor search rather than searching an invented line.', inputSchema: roadtripCorridorRequestSchema.shape, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, @@ -105,7 +113,10 @@ export class RoadtripPlanningMcp { day.geometry.map(([lat, lng]) => ({ lat, lng })), Math.max(1, input.widthKm / 3), ); - const tiles = corridorTiles(line, input.widthKm); + // The line jumps straight from a departure terminal to its arrival, and the car is + // not on that stretch: nothing under it is tiled or on the way (#2428). + const ridden = riddenRanges(line, rideGaps(day.stops)); + const tiles = drivenPieces(line, ridden).flatMap((piece) => corridorTiles(piece, input.widthKm)); const hits = new Map< string, { poi: Awaited>['pois'][number]; alongKm: number; distanceKm: number } @@ -123,7 +134,8 @@ export class RoadtripPlanningMcp { if (found.truncated || found.clamped) truncatedAreas++; for (const poi of found.pois) { const projection = projectOntoRoute(poi, line); - if (!projection || projection.offRouteKm > input.widthKm) continue; + if (!projection || projection.offRouteKm > input.widthKm || inRiddenRange(ridden, projection.alongKm)) + continue; if ( (input.fromKm != null && projection.alongKm < input.fromKm) || (input.toKm != null && projection.alongKm > input.toKm) diff --git a/server/src/systemNotices/registry.ts b/server/src/systemNotices/registry.ts index 956dae54b..1cc8753ce 100644 --- a/server/src/systemNotices/registry.ts +++ b/server/src/systemNotices/registry.ts @@ -48,7 +48,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ titleKey: 'system_notice.release_notes.headline', bodyKey: 'system_notice.release_notes.intro', release: { - version: '4.3.0', + version: '4.3.1', eyebrowKey: 'system_notice.release_notes.eyebrow', headlineKey: 'system_notice.release_notes.headline', introKey: 'system_notice.release_notes.intro', @@ -84,7 +84,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ footnoteKey: 'system_notice.release_notes.footnote', notes: { labelKey: 'system_notice.release_notes.notes_label', - href: 'https://github.com/liketrek/TREK/releases/tag/v4.3.0', + href: 'https://github.com/liketrek/TREK/releases/tag/v4.3.1', }, note: { eyebrowKey: 'system_notice.release_notes.note_eyebrow', diff --git a/server/tests/integration/oidc.test.ts b/server/tests/integration/oidc.test.ts index 62c3f57f7..6f16ac8f7 100644 --- a/server/tests/integration/oidc.test.ts +++ b/server/tests/integration/oidc.test.ts @@ -229,8 +229,14 @@ describe('GET /api/auth/oidc/callback', () => { expect(res.headers.location).toContain('/login?oidc_code='); // Verify user was created in DB - const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get(); + const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get() as { id: number; username: string; role: string } | undefined; expect(newUser).toBeDefined(); + // Registered, the way the audit log reports a password signup, and then logged in: + // an admin reading user.register for who got an account sees the SSO ones too. + const rows = testDb.prepare('SELECT action, details FROM audit_log WHERE user_id = ? ORDER BY id').all(newUser!.id) as + { action: string; details: string | null }[]; + expect(rows.map(r => r.action)).toEqual(['user.register', 'user.login']); + expect(JSON.parse(rows[0].details || '{}')).toEqual({ username: newUser!.username, email: 'newuser@example.com', role: newUser!.role, method: 'oidc' }); }); it('OIDC-006: invalid state → redirects with invalid_state error', async () => { diff --git a/server/tests/unit/db/booked-night-reseat.test.ts b/server/tests/unit/db/booked-night-reseat.test.ts index 58797af18..b02649995 100644 --- a/server/tests/unit/db/booked-night-reseat.test.ts +++ b/server/tests/unit/db/booked-night-reseat.test.ts @@ -3,8 +3,8 @@ * * A night's stop used to go last on its check-in day unless a stop pinned to a later * hour pulled it forward, and the backfill for older bookings appended it as well. The - * night leads its day now (AccommodationsService.positionForCheckIn), and this step - * brings the trips planned under the old rule along, drawn roads included. + * night leads its day now (night-seat.ts, the rule AccommodationsService applies), and + * this step brings the trips planned under the old rule along, drawn roads included. * * The rules live in reseat-booked-nights.ts and are tested there directly on a fully * migrated database: the columns the step reads (assignment_time, accommodation_id) @@ -189,6 +189,108 @@ describe('booked night reseat', () => { expect(reseatBookedNights(db)).toBe(0); }); + it('RESEAT-011: two nights with the same check-in, booked in that order, stay put with their roads', () => { + // Each used to count the other as "at or before" its check-in, so the step reported + // two moves it never made and the reanchor dropped the road between them. + const db = freshDb(); + const [first, second] = [place(db, 'Adlon'), place(db, 'Mercure')]; + stop(db, first, 0, null, night(db, first, '15:00')); + stop(db, second, 1, null, night(db, second, '15:00')); + const between = via(db, 0); + const intoTomorrow = via(db, 1); + + expect(reseatBookedNights(db)).toBe(0); + + expect(order(db)).toEqual([first, second]); + expect(vias(db)).toEqual([ + { id: between, after_order_index: 0, sequence: 0 }, + { id: intoTomorrow, after_order_index: 1, sequence: 0 }, + ]); + expect(reseatBookedNights(db)).toBe(0); + }); + + it('RESEAT-012: two nights with the same check-in settle by their booking, and only once', () => { + const db = freshDb(); + const [fuel, first, second] = [place(db, 'Aral'), place(db, 'Adlon'), place(db, 'Mercure')]; + const firstNight = night(db, first, '15:00'); + const secondNight = night(db, second, '15:00'); + stop(db, fuel, 0); + stop(db, second, 1, null, secondNight); + stop(db, first, 2, null, firstNight); + + expect(reseatBookedNights(db)).toBe(2); + + expect(order(db)).toEqual([first, second, fuel]); + expect(indexes(db)).toEqual([0, 1, 2]); + expect(reseatBookedNights(db)).toBe(0); + expect(order(db)).toEqual([first, second, fuel]); + }); + + it('RESEAT-013: two nights without a check-in settle by their booking too', () => { + // Both used to want the front, and every pass swapped them. + const db = freshDb(); + const [first, second] = [place(db, 'Adlon'), place(db, 'Mercure')]; + const firstNight = night(db, first, null); + const secondNight = night(db, second, null); + stop(db, second, 0, null, secondNight); + stop(db, first, 1, null, firstNight); + + expect(reseatBookedNights(db)).toBe(1); + + expect(order(db)).toEqual([first, second]); + expect(reseatBookedNights(db)).toBe(0); + expect(order(db)).toEqual([first, second]); + }); + + it('RESEAT-014: a night without a check-in leads one with a clock', () => { + const db = freshDb(); + const [fuel, clocked, open] = [place(db, 'Aral'), place(db, 'Adlon'), place(db, 'Mercure')]; + stop(db, fuel, 0); + stop(db, clocked, 1, null, night(db, clocked, '15:00')); + stop(db, open, 2, null, night(db, open, null)); + + reseatBookedNights(db); + + expect(order(db)).toEqual([open, clocked, fuel]); + expect(reseatBookedNights(db)).toBe(0); + expect(order(db)).toEqual([open, clocked, fuel]); + }); + + it('RESEAT-015: an hour pinned on a night own stop is not what the other nights read it by', () => { + // The stop carries eight, the booking carries three, and a night seats itself by + // its check-in. Read the pinned hour off the row instead and the two of them + // answer the same question differently: each counts the other as ahead, the step + // reports two moves on every run for ever, and the swap in between drops the road. + const db = freshDb(); + const [first, second] = [place(db, 'Adlon'), place(db, 'Mercure')]; + const firstNight = night(db, first, '15:00'); + const secondNight = night(db, second, '15:00'); + stop(db, first, 0, null, firstNight); + stop(db, second, 1, '08:00', secondNight); + const between = via(db, 0); + + expect(reseatBookedNights(db)).toBe(0); + + expect(order(db)).toEqual([first, second]); + expect(vias(db)).toEqual([{ id: between, after_order_index: 0, sequence: 0 }]); + expect(reseatBookedNights(db)).toBe(0); + }); + + it('RESEAT-016: a night without a check-in leads on its booking, whatever hour its place carries', () => { + const db = freshDb(); + const [first, second] = [place(db, 'Adlon', '09:00'), place(db, 'Mercure')]; + const firstNight = night(db, first, null); + const secondNight = night(db, second, null); + stop(db, second, 0, null, secondNight); + stop(db, first, 1, null, firstNight); + + expect(reseatBookedNights(db)).toBe(1); + + expect(order(db)).toEqual([first, second]); + expect(reseatBookedNights(db)).toBe(0); + expect(order(db)).toEqual([first, second]); + }); + it('RESEAT-008: runs again without moving anything', () => { const db = freshDb(); const [fuel, hotel] = [place(db, 'Aral'), place(db, 'Rostock')]; diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts index d017e2332..429327ace 100644 --- a/server/tests/unit/nest/accommodations.service.test.ts +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -789,6 +789,73 @@ describe('the day stop a booking implies', () => { expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]); }); + it('ACC-022j two nights with the same check-in settle by their booking and stay settled through a change of notes', () => { + // Each used to read the other as "at or before" its own check-in, so a notes edit on + // the front one moved it behind the other, took the road between them with it, and + // a notes edit on the other swapped them back. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const [first, second] = ['Adlon', 'Mercure'].map(name => createPlace(testDb, trip.id, { name })); + const a = svc.createAccommodation(trip.id, { place_id: first.id, start_day_id: day.id, end_day_id: day.id, check_in: '15:00' }) as any; + const b = svc.createAccommodation(trip.id, { place_id: second.id, start_day_id: day.id, end_day_id: day.id, check_in: '15:00' }) as any; + expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id]); + const between = Number(testDb.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 0, 0, 52.5, 13.4)') + .run(day.id).lastInsertRowid); + + for (const booked of [a, b]) { + const existing = svc.getAccommodation(booked.accommodation.id, trip.id)!; + const quiet = svc.updateAccommodation(booked.accommodation.id, existing, { notes: 'late arrival' }) as any; + expect(quiet.mirror.moved).toBeNull(); + expect(quiet.mirror.vias).toBeUndefined(); + } + expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id]); + expect(testDb.prepare('SELECT after_order_index FROM roadtrip_vias WHERE id = ?').get(between)).toEqual({ after_order_index: 0 }); + + // Dragged the other way round on purpose: an equal clock is no contradiction. + testDb.prepare('UPDATE day_assignments SET order_index = CASE place_id WHEN ? THEN 1 ELSE 0 END WHERE day_id = ?').run(first.id, day.id); + for (const booked of [a, b]) { + const existing = svc.getAccommodation(booked.accommodation.id, trip.id)!; + expect((svc.updateAccommodation(booked.accommodation.id, existing, { notes: 'later' }) as any).mirror.moved).toBeNull(); + } + expect(stopsOn(day.id).map(s => s.place_id)).toEqual([second.id, first.id]); + }); + + it('ACC-022k two nights without a check-in: the earlier booking leads', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const [museum, first, second] = ['Pergamon', 'Adlon', 'Mercure'].map(name => createPlace(testDb, trip.id, { name })); + createDayAssignment(testDb, day.id, museum.id); + svc.createAccommodation(trip.id, { place_id: first.id, start_day_id: day.id, end_day_id: day.id }); + const b = svc.createAccommodation(trip.id, { place_id: second.id, start_day_id: day.id, end_day_id: day.id }) as any; + + expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]); + const existing = svc.getAccommodation(b.accommodation.id, trip.id)!; + expect((svc.updateAccommodation(b.accommodation.id, existing, { notes: 'late' }) as any).mirror.moved).toBeNull(); + expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]); + }); + + it('ACC-022l a night on a day dragged out of clock order is settled where a fresh seat puts it', () => { + // Pinned two, then pinned eight, on purpose. A check-in at ten goes behind the eight, + // and nothing else satisfies both clocks; a change of notes used to relocate it to + // the very same seat and report a move for it. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const [late, early, hotel] = ['Museum', 'Aral', 'Rostock'].map(name => createPlace(testDb, trip.id, { name })); + testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(createDayAssignment(testDb, day.id, late.id).id); + testDb.prepare("UPDATE day_assignments SET assignment_time = '08:00' WHERE id = ?").run(createDayAssignment(testDb, day.id, early.id).id); + const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }) as any; + expect(stopsOn(day.id).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]); + + const existing = svc.getAccommodation(accommodation.id, trip.id)!; + const quiet = svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' }) as any; + + expect(quiet.mirror.moved).toBeNull(); + expect(stopsOn(day.id).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]); + }); + it('ACC-023 the place is typed as lodging, so the rail draws it as a service stop', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/nest/days.service.test.ts b/server/tests/unit/nest/days.service.test.ts index 466f65e8f..cc77f56b2 100644 --- a/server/tests/unit/nest/days.service.test.ts +++ b/server/tests/unit/nest/days.service.test.ts @@ -604,6 +604,91 @@ describe('resyncAccommodationDays', () => { expect(testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(res)) .toMatchObject({ day_id: null, reservation_time: null }); }); + + // The stop a booking wrote goes along when the stay is carried to another day row. + const stopsOn = (dayId: number) => + testDb.prepare('SELECT place_id, order_index, accommodation_id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(dayId) as + { place_id: number; order_index: number; accommodation_id: number | null }[]; + const addVia = (dayId: number, afterOrderIndex: number) => + Number(testDb.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, 0, 48.1, 11.5)') + .run(dayId, afterOrderIndex).lastInsertRowid); + const viasOn = (dayId: number) => + testDb.prepare('SELECT id, after_order_index FROM roadtrip_vias WHERE day_id = ? ORDER BY id').all(dayId) as + { id: number; after_order_index: number }[]; + + it('DAY-SVC-055: the carried stop is seated by its check-in on its new day, and both days keep their roads in place', () => { + // The trip gained a day at the front: d1 now holds the day before, d2 the stay's + // date. The night used to be appended behind d2's unpinned stops, the very order + // a night booked today no longer gets, and neither day's roads were re-pinned. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const d1 = createDay(testDb, trip.id, { date: '2026-05-01' }); + const d2 = createDay(testDb, trip.id, { date: '2026-05-02' }); + const [hotel, a, b, c, d] = ['Rostock', 'Aral', 'Hafen', 'Museum', 'Markt'].map(name => createPlace(testDb, trip.id, { name })); + const { accommodation } = accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: d1.id, end_day_id: d1.id, check_in: '10:00' }) as any; + createDayAssignment(testDb, d1.id, a.id); + createDayAssignment(testDb, d1.id, b.id); + createDayAssignment(testDb, d2.id, c.id); + createDayAssignment(testDb, d2.id, d.id); + expect(stopsOn(d1.id).map(s => s.place_id)).toEqual([hotel.id, a.id, b.id]); + const outOfHotel = addVia(d1.id, 0); + const outOfA = addVia(d1.id, 1); + const outOfC = addVia(d2.id, 0); + testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-04-30', d1.id); + testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-01', d2.id); + + svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02']])); + + expect(testDb.prepare('SELECT start_day_id FROM day_accommodations WHERE id = ?').get(accommodation.id)).toMatchObject({ start_day_id: d2.id }); + expect(stopsOn(d2.id)).toEqual([ + { place_id: hotel.id, order_index: 0, accommodation_id: accommodation.id }, + { place_id: c.id, order_index: 1, accommodation_id: null }, + { place_id: d.id, order_index: 2, accommodation_id: null }, + ]); + expect(stopsOn(d1.id).map(s => [s.place_id, s.order_index])).toEqual([[a.id, 0], [b.id, 1]]); + // The hotel's road on d1 had no stop ahead to fall back on and goes; A's follows A + // to leg zero. On d2 the road out of C is one number further on, behind C. + expect(viasOn(d1.id)).toEqual([{ id: outOfA, after_order_index: 0 }]); + expect(viasOn(d1.id).map(v => v.id)).not.toContain(outOfHotel); + expect(viasOn(d2.id)).toEqual([{ id: outOfC, after_order_index: 1 }]); + }); + + it('DAY-SVC-056: a day that already holds the place by hand keeps that stop, and the booking rides along with it', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const d1 = createDay(testDb, trip.id, { date: '2026-05-01' }); + const d2 = createDay(testDb, trip.id, { date: '2026-05-02' }); + const hotel = createPlace(testDb, trip.id, { name: 'Rostock' }); + const { accommodation } = accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: d1.id, end_day_id: d1.id }) as any; + const own = createDayAssignment(testDb, d2.id, hotel.id); + testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-04-30', d1.id); + testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-01', d2.id); + + svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02']])); + + expect(testDb.prepare('SELECT start_day_id FROM day_accommodations WHERE id = ?').get(accommodation.id)).toMatchObject({ start_day_id: d2.id }); + expect(stopsOn(d1.id)).toEqual([]); + expect(stopsOn(d2.id)).toEqual([{ place_id: hotel.id, order_index: own.order_index, accommodation_id: null }]); + }); + + it('DAY-SVC-057: a stay whose end alone moves leaves its stop where it is', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const d1 = createDay(testDb, trip.id, { date: '2026-05-01' }); + const d2 = createDay(testDb, trip.id, { date: '2026-05-02' }); + const d3 = createDay(testDb, trip.id, { date: '2026-05-03' }); + const [hotel, a] = ['Rostock', 'Aral'].map(name => createPlace(testDb, trip.id, { name })); + const { accommodation } = accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: d1.id, end_day_id: d2.id, check_in: '10:00' }) as any; + createDayAssignment(testDb, d1.id, a.id); + testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-03', d2.id); + testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-02', d3.id); + + svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02'], [d3.id, '2026-05-03']])); + + expect(testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(accommodation.id)) + .toMatchObject({ start_day_id: d1.id, end_day_id: d3.id }); + expect(stopsOn(d1.id).map(s => [s.place_id, s.order_index])).toEqual([[hotel.id, 0], [a.id, 1]]); + }); }); describe('reorder', () => { diff --git a/server/tests/unit/nest/maps.autocomplete.test.ts b/server/tests/unit/nest/maps.autocomplete.test.ts index a51cc808d..49e4f132b 100644 --- a/server/tests/unit/nest/maps.autocomplete.test.ts +++ b/server/tests/unit/nest/maps.autocomplete.test.ts @@ -1,5 +1,5 @@ /** - * MAPS-AUTO-001..008 — the suggestion list behind the place search box. + * MAPS-AUTO-001..013: the suggestion list behind the place search box. * * This is the path the index was built for. Nominatim's usage policy names * autocomplete as unacceptable use in its own words, whatever the rate, and the @@ -73,19 +73,32 @@ const osmHit = (over: Record = {}) => ({ }); /** - * `enabled` drives the admin kill switch, which is read straight off - * app_settings; an unset row reads as on, because the switch is fail-open. + * `enabled` drives the index switch, an environment variable; `rows` are the + * app_settings the instance holds, by key: a Google key, the provider choice, + * the Google-only switch. Anything else reads as absent. * * Keyed on the statement rather than answering everything the same way: the * same `get` also resolves the Google key, and a blanket answer would hand * `'false'` to the key resolver and send the fallback at Google for real. */ -function make(enabled = true) { +function make(enabled = true, rows: Record = {}) { trekPlaces.on = enabled; - const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService; + const database = { + get: vi.fn((sql: string, key?: unknown) => + typeof key === 'string' && sql.includes('app_settings') && rows[key] !== undefined ? { value: rows[key] } : undefined, + ), + } as unknown as DatabaseService; return new MapsService(database, {} as PlacePhotoCacheService); } +/** Google's autocomplete envelope, which is not the shape its text search answers in. */ +const googleSuggestions = (name: string) => ({ + ok: true, + json: async () => ({ + suggestions: [{ placePrediction: { placeId: 'g1', structuredFormat: { mainText: { text: name }, secondaryText: { text: 'Chiyoda' } } } }], + }), +}); + beforeEach(() => { mockSearch.mockReset(); mockSearch.mockResolvedValue([]); @@ -229,4 +242,43 @@ describe('MapsService.autocompletePlaces', () => { expect(mockSearch).not.toHaveBeenCalled(); expect(result.source).not.toBe('trek-places'); }); + + it('MAPS-AUTO-012: the Google-only switch sends the keystroke to Google and skips the index', async () => { + // The admin row promises every search AND every suggestion. The search half + // is pinned next door (MAPS-SEARCH-011); this is the other call site. + mockSearch.mockResolvedValue([hit()]); + const fetchMock = vi.fn().mockResolvedValue(googleSuggestions('Tokyo Station')); + vi.stubGlobal('fetch', fetchMock); + + const result = await make(true, { maps_api_key: 'key', places_google_only: 'true' }).autocompletePlaces(1, INPUT); + + expect(mockSearch).not.toHaveBeenCalled(); + expect(result.source).toBe('google'); + expect(result.suggestions[0]).toMatchObject({ placeId: 'g1', mainText: 'Tokyo Station' }); + expect(String(fetchMock.mock.calls[0][0])).toContain('places:autocomplete'); + vi.unstubAllGlobals(); + }); + + it('MAPS-AUTO-013: the switch changes nothing without a Google key, or when Google does not hold the slot', async () => { + mockSearch.mockResolvedValue([hit()]); + + // No key at all: the index answers, as before. + const keyless = await make(true, { places_google_only: 'true' }).autocompletePlaces(1, INPUT); + expect(mockSearch).toHaveBeenCalledTimes(1); + expect(keyless.source).toBe('trek-places'); + + // A key, but the admin picked OpenStreetMap: Google holds no slot, so + // there is nothing for the switch to hand the keystroke to. + mockSearch.mockClear(); + const osmOnly = await make(true, { maps_api_key: 'key', places_google_only: 'true', places_provider: 'openstreetmap' }) + .autocompletePlaces(1, INPUT); + expect(mockSearch).toHaveBeenCalledTimes(1); + expect(osmOnly.source).toBe('trek-places'); + + // A key and the switch off: the index still answers first. + mockSearch.mockClear(); + const off = await make(true, { maps_api_key: 'key', places_google_only: 'false' }).autocompletePlaces(1, INPUT); + expect(mockSearch).toHaveBeenCalledTimes(1); + expect(off.source).toBe('trek-places'); + }); }); diff --git a/server/tests/unit/nest/oidc.controller.test.ts b/server/tests/unit/nest/oidc.controller.test.ts index 2f4e57bee..bc71a1917 100644 --- a/server/tests/unit/nest/oidc.controller.test.ts +++ b/server/tests/unit/nest/oidc.controller.test.ts @@ -221,6 +221,25 @@ describe('OidcController /callback', () => { expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac'); }); + it('OIDC-CB-LOGIN-003: an account the callback creates writes the user.register row a password signup writes, then the login', async () => { + const res = makeRes(); + const reqIp = { query: {}, headers: {}, ip: '203.0.113.9', cookies: { trek_oidc_state: 's' } } as unknown as Request; + await ctl(svc({ + exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }), + verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }), + getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }), + findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 7, username: 'a', email: 'a@b.c', role: 'user' }, created: true }), + })).callback('c', 's', undefined, reqIp, res); + + expect(writeAudit).toHaveBeenCalledTimes(2); + expect(writeAudit).toHaveBeenNthCalledWith(1, { + userId: 7, action: 'user.register', ip: '203.0.113.9', + details: { username: 'a', email: 'a@b.c', role: 'user', method: 'oidc' }, + }); + expect(writeAudit).toHaveBeenNthCalledWith(2, { userId: 7, action: 'user.login', ip: '203.0.113.9', details: { method: 'oidc' } }); + expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac'); + }); + it('OIDC-CB-LOGIN-002: a login refused by the user lookup writes no login row', async () => { const res = makeRes(); await ctl(svc({ diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index 87ff3945c..a43cece1e 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -1309,6 +1309,40 @@ describe('the day stop a hotel booking implies', () => { expect(stopsOn(days[0].id)).toHaveLength(1); }); + it('RESV-STAY-008: a save from the booking form re-seats the night only when its check-in changed', () => { + // The form sends the whole stay on every save, check-in included, so this is the + // door a title edit on a hotel booking comes through. Its own reading of "the + // check-in changed" (the prior row, read before the write) decides whether a night + // the traveller dragged is left alone or seated afresh, the way ACC-022g pins it + // for the stay route. + const { trip, days } = tripWithDays(); + const [harbour, market, hotel] = ['Hafen', 'Markt', 'Rostock'].map(name => createPlace(testDb, trip.id, { name })); + createDayAssignment(testDb, days[0].id, harbour.id); + createDayAssignment(testDb, days[0].id, market.id); + const stay = (check_in: string) => ({ place_id: hotel.id, start_day_id: days[0].id, end_day_id: days[0].id, check_in }); + const { reservation } = svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', create_accommodation: stay('15:00') }); + const order = () => stopsOn(days[0].id).map(s => s.place_id); + expect(order()).toEqual([hotel.id, harbour.id, market.id]); + + // Dragged to the end of the day by hand. + const own = stopsOn(days[0].id).find(s => s.place_id === hotel.id)!; + testDb.prepare('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > 0').run(days[0].id); + testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id); + expect(order()).toEqual([harbour.id, market.id, hotel.id]); + + vi.clearAllMocks(); + let current = svc.getReservation(String(reservation.id), String(trip.id))!; + svc.update(String(reservation.id), String(trip.id), { title: 'Hotel, late arrival', type: 'hotel', create_accommodation: stay('15:00') } as never, current); + expect(order()).toEqual([harbour.id, market.id, hotel.id]); + expect(broadcast).not.toHaveBeenCalledWith(String(trip.id), 'assignment:moved', expect.anything()); + + current = svc.getReservation(String(reservation.id), String(trip.id))!; + svc.update(String(reservation.id), String(trip.id), { type: 'hotel', create_accommodation: stay('10:00') } as never, current); + expect(order()).toEqual([hotel.id, harbour.id, market.id]); + expect(stopsOn(days[0].id).map(s => s.id)).toContain(own.id); + expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'assignment:moved', expect.objectContaining({ assignment: expect.objectContaining({ id: own.id }) })); + }); + it('RESV-STAY-007: a foreign accommodation_id reaches no stop on the other trip', () => { // The trip_id guard on the stay delete is what denies that reach; the stops go // by accommodation id alone and would otherwise follow it straight over. diff --git a/server/tests/unit/nest/roadtrip-plan.service.test.ts b/server/tests/unit/nest/roadtrip-plan.service.test.ts index ea9c84419..bdaa347cc 100644 --- a/server/tests/unit/nest/roadtrip-plan.service.test.ts +++ b/server/tests/unit/nest/roadtrip-plan.service.test.ts @@ -8,7 +8,7 @@ import { db } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service'; -import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories'; +import { createDay, createDayAccommodation, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories'; import { resetTestDb } from '../../helpers/test-db'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -117,6 +117,46 @@ describe('a visit end time on the road trip', () => { }); }); +describe('a booked night on the road trip (#2410)', () => { + // The night flag comes off the planning SELECT (stay.id), not off a row the tests + // hand the service, so a column dropped from the statement would flip every stop + // to a night here and nowhere else. + it('marks the stop the night is booked at, and a day that is only its night stands among the days', async () => { + const { user, trip, plans } = setup(); + const arrival = createDay(db, trip.id); + const hotel = createPlace(db, trip.id, { name: 'Rostock', lat: 54.09, lng: 12.1 }); + createDayAssignment(db, arrival.id, hotel.id); + createDayAccommodation(db, trip.id, hotel.id, arrival.id, arrival.id, { check_in: '15:00' }); + + const { context, calculated } = await plans.calculate(trip.id, user.id); + + expect(context.visits.map((v) => [v.name, v.stay_id !== null])).toEqual([ + ['Hamburg', false], + ['Lueneburg', false], + ['Celle', false], + ['Rostock', true], + ]); + expect(calculated.quietDays).toEqual([]); + expect(calculated.days.map((d) => d.dayId)).toContain(arrival.id); + const night = calculated.days.find((d) => d.dayId === arrival.id)!; + expect(night.stops.map((s) => [s.name, s.night])).toEqual([['Rostock', true]]); + expect(night.legs).toEqual([]); + expect(night.schedule.entries[0]).toMatchObject({ arrival: '15:00', anchored: true }); + }); + + it('files a lone stop that is no night under the quiet days', async () => { + const { user, trip, plans } = setup(); + const quiet = createDay(db, trip.id); + const museum = createPlace(db, trip.id, { name: 'Museum', lat: 54.09, lng: 12.1 }); + createDayAssignment(db, quiet.id, museum.id); + + const { calculated } = await plans.calculate(trip.id, user.id); + + expect(calculated.days.map((d) => d.dayId)).not.toContain(quiet.id); + expect(calculated.quietDays.map((d) => d.dayId)).toEqual([quiet.id]); + }); +}); + describe('a booking the traveller rides (#2428)', () => { function withFlight(dayId: number, tripId: number, over: { end_day_id?: number; dep?: string; arr?: string } = {}) { const result = db diff --git a/server/tests/unit/nest/roadtrip-planning.test.ts b/server/tests/unit/nest/roadtrip-planning.test.ts index 0d3e26e0b..b175e144a 100644 --- a/server/tests/unit/nest/roadtrip-planning.test.ts +++ b/server/tests/unit/nest/roadtrip-planning.test.ts @@ -302,6 +302,7 @@ describe('Roadtrip MCP registration and search', () => { days: [ { dayNumber: 1, + stops: [], geometry: [ [48, 10], [48, 10.1], @@ -388,7 +389,7 @@ describe('MCP trip preferences authorization', () => { describe('corridor filtering', () => { const input = { tripId: 10, dayNumber: 1, category: 'charging' as const, widthKm: 5, offset: 0 }; function tool() { - const plan = { failures: [] as unknown[], omittedVisits: [] as number[], calculated: { dayWindowIssue: null as string | null, days: [{ dayNumber: 1, geometry: [[48, 10], [48, 10.1]] }] } }; + const plan = { failures: [] as unknown[], omittedVisits: [] as number[], calculated: { dayWindowIssue: null as string | null, days: [{ dayNumber: 1, stops: [], geometry: [[48, 10], [48, 10.1]] }] } }; const maps = { search: vi.fn(async () => ({ pois: [ { osm_id: 'a', name: 'Fast', lat: 48, lng: 10.03, category: 'charging', charging: { sockets: [{ type: 'type2', kw: 150 }] } }, { osm_id: 'b', name: 'Slow', lat: 48, lng: 10.07, category: 'charging', charging: { sockets: [{ type: 'type2', kw: 11 }] } }, @@ -421,6 +422,44 @@ describe('corridor filtering', () => { expect((await read({ toKm: 0 })).hits).toHaveLength(0); expect((await read({})).failedSources).toEqual(['plugin']); }); + it('leaves the ride between two terminals out of the search and drops a hit under the flight path (#2428)', async () => { + // The day drives to the airport, flies two degrees north and east, and drives on + // from the far one: the geometry jumps straight between the terminals, and the + // browser hook already cuts that stretch out. The tool tiles the same pieces. + const terminal = (role: 'departure' | 'arrival', lat: number, lng: number) => ({ + carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: null, at: null }, + lat, + lng, + }); + const plan = { + failures: [], + omittedVisits: [], + calculated: { + dayWindowIssue: null, + days: [ + { + dayNumber: 1, + stops: [{ lat: 48, lng: 10 }, terminal('departure', 48, 10.1), terminal('arrival', 50, 12), { lat: 50, lng: 12.1 }], + geometry: [[48, 10], [48, 10.1], [50, 12], [50, 12.1]], + }, + ], + }, + }; + const underTheFlight = { lat: 49, lng: 11 }; + type Box = { south: number; north: number; west: number; east: number }; + const maps = { search: vi.fn(async (_query: { bbox: Box }) => ({ pois: [ + { osm_id: 'road', name: 'On the road', lat: 48, lng: 10.05, category: 'fuel' }, + { osm_id: 'field', name: 'Under the flight', ...underTheFlight, category: 'fuel' }, + ], sources: ['osm'], failedSources: [], truncated: false, clamped: false })) }; + const mcp = new RoadtripPlanningMcp({ calculate: async () => plan } as never, maps as never, {} as never); + const body = JSON.parse((await mcp.corridor({ ...input, category: 'fuel' }, ctx)).content[0].text as string); + const asked = maps.search.mock.calls.map(([query]) => query.bbox); + expect(asked.length).toBeGreaterThan(0); + expect(asked.some((b) => b.south <= underTheFlight.lat && b.north >= underTheFlight.lat && b.west <= underTheFlight.lng && b.east >= underTheFlight.lng)).toBe(false); + expect(body.hits.map((h: { poi: { osm_id: string } }) => h.poi.osm_id)).toEqual(['road']); + expect(body.totalAreas).toBe(asked.length); + expect(body.complete).toBe(true); + }); }); it('reads the saved roadtrip context without routing', async () => { const s = setup(); From cb85ba915c229fa2f415fa03aa1ab98387789671 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 22 Sep 2026 12:10:20 +0200 Subject: [PATCH 2/4] fix(roadtrip): one ride-gap rule for both corridor searches, and two seam edges The corridor search in the browser cut its tiles around a ride, the MCP tool did not: it tiled and measured along the straight line between two terminals, so an assistant searching a flight day got boxes and hits under the flight path. The gap logic moves into shared/roadtrip/corridor.ts and both sides use it. A departure less than the check-in allowance after midnight no longer pins on the previous evening, and a hire car whose pick-up desk has no position is not seamed at its return desk. --- shared/src/i18n/ar/admin.ts | 1 + shared/src/i18n/ar/collab.ts | 2 + shared/src/i18n/br/admin.ts | 1 + shared/src/i18n/br/collab.ts | 2 + shared/src/i18n/ca/admin.ts | 1 + shared/src/i18n/ca/collab.ts | 2 + shared/src/i18n/cs/admin.ts | 1 + shared/src/i18n/cs/collab.ts | 2 + shared/src/i18n/de/admin.ts | 1 + shared/src/i18n/de/collab.ts | 2 + shared/src/i18n/en/admin.ts | 1 + shared/src/i18n/en/collab.ts | 2 + shared/src/i18n/es/admin.ts | 1 + shared/src/i18n/es/collab.ts | 2 + shared/src/i18n/fr/admin.ts | 1 + shared/src/i18n/fr/collab.ts | 2 + shared/src/i18n/gr/admin.ts | 1 + shared/src/i18n/gr/collab.ts | 2 + shared/src/i18n/hu/admin.ts | 1 + shared/src/i18n/hu/collab.ts | 2 + shared/src/i18n/id/admin.ts | 1 + shared/src/i18n/id/collab.ts | 2 + shared/src/i18n/it/admin.ts | 1 + shared/src/i18n/it/collab.ts | 2 + shared/src/i18n/ja/admin.ts | 1 + shared/src/i18n/ja/collab.ts | 2 + shared/src/i18n/ko/admin.ts | 1 + shared/src/i18n/ko/collab.ts | 2 + shared/src/i18n/nl/admin.ts | 1 + shared/src/i18n/nl/collab.ts | 2 + shared/src/i18n/pl/admin.ts | 1 + shared/src/i18n/pl/collab.ts | 2 + shared/src/i18n/ru/admin.ts | 1 + shared/src/i18n/ru/collab.ts | 2 + shared/src/i18n/sv/admin.ts | 1 + shared/src/i18n/sv/collab.ts | 2 + shared/src/i18n/tr/admin.ts | 1 + shared/src/i18n/tr/collab.ts | 2 + shared/src/i18n/uk/admin.ts | 1 + shared/src/i18n/uk/collab.ts | 2 + shared/src/i18n/vi/admin.ts | 1 + shared/src/i18n/vi/collab.ts | 2 + shared/src/i18n/zh-TW/admin.ts | 1 + shared/src/i18n/zh-TW/collab.ts | 2 + shared/src/i18n/zh/admin.ts | 1 + shared/src/i18n/zh/collab.ts | 2 + shared/src/maps/maps.schema.ts | 4 +- shared/src/roadtrip/carriers.spec.ts | 117 +++++++++++++++++++++++---- shared/src/roadtrip/carriers.ts | 38 ++++----- shared/src/roadtrip/corridor.spec.ts | 110 +++++++++++++++++++++++++ shared/src/roadtrip/corridor.ts | 82 +++++++++++++++++++ 51 files changed, 381 insertions(+), 39 deletions(-) diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index c6e2384a8..dbf9d5afb 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -198,6 +198,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'البحث عبر Google فقط', 'admin.placesGoogleOnly.subtitle': 'كل بحث وكل اقتراح يذهب إلى Google Places. عند الإيقاف يجيب فهرس TREK وOpenStreetMap أولًا، ولا يُسأل Google إلا إذا لم يجدا شيئًا.', 'admin.placesGoogleOnly.missingKey': 'يتطلب مفتاح Google Maps API. من دونه يعمل البحث عبر فهرس TREK وOpenStreetMap مهما كان وضع هذا المفتاح.', + 'admin.placesGoogleOnly.otherProvider': 'يتطلب Google كمزود للأماكن. عند اختيار Amap أو OpenStreetMap لا يذهب أي بحث إلى Google مهما كان وضع هذا المفتاح.', 'admin.transitProvider.title': 'مزود النقل العام', 'admin.transitProvider.subtitle': 'الخدمة التي تجيب على بحث النقل العام.', 'admin.transitProvider.transitous': 'Transitous (مجاني)', diff --git a/shared/src/i18n/ar/collab.ts b/shared/src/i18n/ar/collab.ts index 4c41584a1..4f3a1abaa 100644 --- a/shared/src/i18n/ar/collab.ts +++ b/shared/src/i18n/ar/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'حذف الرابط', 'collab.links.edit': 'تعديل الرابط', 'collab.links.open': 'فتح الرابط', + 'collab.links.confirmDeleteTitle': 'حذف الرابط؟', + 'collab.links.confirmDeleteBody': 'سيتم حذف هذا الرابط نهائيًا.', 'collab.whatsNext.title': 'ما التالي', 'collab.whatsNext.today': 'اليوم', 'collab.whatsNext.tomorrow': 'غدًا', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 926cd1ef1..d3bec71f2 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -176,6 +176,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Pesquisar apenas com o Google', 'admin.placesGoogleOnly.subtitle': 'Toda pesquisa e toda sugestão vão para o Google Places. Desligado, o índice do TREK e o OpenStreetMap respondem primeiro, e o Google só é consultado se eles não encontrarem nada.', 'admin.placesGoogleOnly.missingKey': 'Precisa de uma chave de API do Google Maps. Sem ela, a pesquisa usa o índice do TREK e o OpenStreetMap, independentemente desta chave.', + 'admin.placesGoogleOnly.otherProvider': 'Precisa do Google como provedor de lugares. Com Amap ou OpenStreetMap selecionado, nenhuma busca vai ao Google, seja qual for a posição deste botão.', 'admin.transitProvider.title': 'Provedor de transporte público', 'admin.transitProvider.subtitle': 'Qual serviço responde à busca de transporte público.', 'admin.transitProvider.transitous': 'Transitous (grátis)', diff --git a/shared/src/i18n/br/collab.ts b/shared/src/i18n/br/collab.ts index 630e72674..020225c14 100644 --- a/shared/src/i18n/br/collab.ts +++ b/shared/src/i18n/br/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Excluir link', 'collab.links.edit': 'Editar link', 'collab.links.open': 'Abrir link', + 'collab.links.confirmDeleteTitle': 'Excluir link?', + 'collab.links.confirmDeleteBody': 'Este link será excluído permanentemente.', 'collab.whatsNext.title': 'Próximos passos', 'collab.whatsNext.today': 'Hoje', 'collab.whatsNext.tomorrow': 'Amanhã', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 91cf3e46b..99126322e 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -169,6 +169,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Cercar només amb Google', 'admin.placesGoogleOnly.subtitle': 'Cada cerca i cada suggeriment van a Google Places. Desactivat, responen primer l\'índex de TREK i OpenStreetMap, i Google només es consulta si no troben res.', 'admin.placesGoogleOnly.missingKey': 'Cal una clau d\'API de Google Maps. Sense clau, la cerca fa servir l\'índex de TREK i OpenStreetMap, sigui com sigui aquest interruptor.', + 'admin.placesGoogleOnly.otherProvider': 'Necessita Google com a proveïdor de llocs. Amb Amap o OpenStreetMap seleccionats, cap cerca va a Google, digui el que digui aquest interruptor.', 'admin.transitProvider.title': 'Proveïdor de transport públic', 'admin.transitProvider.subtitle': 'Quin servei respon la cerca de transport públic.', 'admin.transitProvider.transitous': 'Transitous (gratuït)', diff --git a/shared/src/i18n/ca/collab.ts b/shared/src/i18n/ca/collab.ts index acddf0dd3..296f33d05 100644 --- a/shared/src/i18n/ca/collab.ts +++ b/shared/src/i18n/ca/collab.ts @@ -17,6 +17,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Suprimeix l\'enllaç', 'collab.links.edit': 'Edita l\'enllaç', 'collab.links.open': 'Obre l\'enllaç', + 'collab.links.confirmDeleteTitle': 'Suprimir l\'enllaç?', + 'collab.links.confirmDeleteBody': 'Aquest enllaç se suprimirà permanentment.', 'collab.whatsNext.title': 'Què ve ara', 'collab.whatsNext.today': 'Avui', 'collab.whatsNext.tomorrow': 'Demà', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 23000b113..85a635ede 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -178,6 +178,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Hledat pouze přes Google', 'admin.placesGoogleOnly.subtitle': 'Každé hledání a každý návrh jde do Google Places. Vypnuto: nejprve odpovídá index TREK a OpenStreetMap, Google se ptáme jen tehdy, když nic nenajdou.', 'admin.placesGoogleOnly.missingKey': 'Vyžaduje klíč Google Maps API. Bez něj hledání běží přes index TREK a OpenStreetMap bez ohledu na tento přepínač.', + 'admin.placesGoogleOnly.otherProvider': 'Vyžaduje Google jako poskytovatele míst. Při zvoleném Amapu nebo OpenStreetMap nejde na Google žádné hledání, ať je tento přepínač nastaven jakkoli.', 'admin.transitProvider.title': 'Poskytovatel veřejné dopravy', 'admin.transitProvider.subtitle': 'Která služba odpovídá na vyhledávání veřejné dopravy.', 'admin.transitProvider.transitous': 'Transitous (zdarma)', diff --git a/shared/src/i18n/cs/collab.ts b/shared/src/i18n/cs/collab.ts index 55f71fa69..9fa356d84 100644 --- a/shared/src/i18n/cs/collab.ts +++ b/shared/src/i18n/cs/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Smazat odkaz', 'collab.links.edit': 'Upravit odkaz', 'collab.links.open': 'Otevřít odkaz', + 'collab.links.confirmDeleteTitle': 'Smazat odkaz?', + 'collab.links.confirmDeleteBody': 'Tento odkaz bude trvale smazán.', 'collab.whatsNext.title': 'Co následuje', 'collab.whatsNext.today': 'Dnes', 'collab.whatsNext.tomorrow': 'Zítra', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 2849a63ed..917993e62 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -179,6 +179,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Nur mit Google suchen', 'admin.placesGoogleOnly.subtitle': 'Jede Suche und jeder Vorschlag geht an Google Places. Aus, antworten zuerst der TREK-Index und OpenStreetMap, Google wird nur gefragt, wenn beide nichts finden.', 'admin.placesGoogleOnly.missingKey': 'Braucht einen Google-Maps-API-Schlüssel. Ohne ihn läuft die Suche über den TREK-Index und OpenStreetMap, egal wie dieser Schalter steht.', + 'admin.placesGoogleOnly.otherProvider': 'Braucht Google als Orts-Anbieter. Mit Amap oder OpenStreetMap als Auswahl geht keine Suche an Google, egal wie dieser Schalter steht.', 'admin.transitProvider.title': 'Verkehrsanbieter', 'admin.transitProvider.subtitle': 'Welcher Dienst die ÖPNV-Suche beantwortet.', 'admin.transitProvider.transitous': 'Transitous (kostenlos)', diff --git a/shared/src/i18n/de/collab.ts b/shared/src/i18n/de/collab.ts index 453b2396c..d7aca4aa6 100644 --- a/shared/src/i18n/de/collab.ts +++ b/shared/src/i18n/de/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Link löschen', 'collab.links.edit': 'Link bearbeiten', 'collab.links.open': 'Link öffnen', + 'collab.links.confirmDeleteTitle': 'Link löschen?', + 'collab.links.confirmDeleteBody': 'Dieser Link wird dauerhaft gelöscht.', 'collab.whatsNext.title': 'Nächste', 'collab.whatsNext.today': 'Heute', 'collab.whatsNext.tomorrow': 'Morgen', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index f9d9d7322..639453714 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -218,6 +218,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Search with Google only', 'admin.placesGoogleOnly.subtitle': 'Every search and every suggestion goes to Google Places. Off, TREK\'s own index and OpenStreetMap answer first and Google is only asked when they find nothing.', 'admin.placesGoogleOnly.missingKey': 'Needs a Google Maps API key. Without one, search runs on TREK\'s own index and OpenStreetMap whatever this switch says.', + 'admin.placesGoogleOnly.otherProvider': 'Needs Google as the places provider. With Amap or OpenStreetMap picked, search never goes to Google whatever this switch says.', 'admin.transitProvider.title': 'Transit Provider', 'admin.transitProvider.subtitle': 'Which service answers public transit search.', 'admin.transitProvider.transitous': 'Transitous (free)', diff --git a/shared/src/i18n/en/collab.ts b/shared/src/i18n/en/collab.ts index b7a9fc9f9..06d5f3bab 100644 --- a/shared/src/i18n/en/collab.ts +++ b/shared/src/i18n/en/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Delete link', 'collab.links.edit': 'Edit link', 'collab.links.open': 'Open link', + 'collab.links.confirmDeleteTitle': 'Delete link?', + 'collab.links.confirmDeleteBody': 'This link will be permanently deleted.', 'collab.whatsNext.title': "What's Next", 'collab.whatsNext.today': 'Today', 'collab.whatsNext.tomorrow': 'Tomorrow', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index e4861e457..a4f62c39f 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -178,6 +178,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Buscar solo con Google', 'admin.placesGoogleOnly.subtitle': 'Cada búsqueda y cada sugerencia van a Google Places. Desactivado, responden primero el índice de TREK y OpenStreetMap, y Google solo se consulta si no encuentran nada.', 'admin.placesGoogleOnly.missingKey': 'Necesita una clave de API de Google Maps. Sin ella, la búsqueda usa el índice de TREK y OpenStreetMap, esté como esté este interruptor.', + 'admin.placesGoogleOnly.otherProvider': 'Necesita Google como proveedor de lugares. Con Amap u OpenStreetMap seleccionados, ninguna búsqueda va a Google, diga lo que diga este interruptor.', 'admin.transitProvider.title': 'Proveedor de transporte público', 'admin.transitProvider.subtitle': 'Qué servicio responde a la búsqueda de transporte público.', 'admin.transitProvider.transitous': 'Transitous (gratis)', diff --git a/shared/src/i18n/es/collab.ts b/shared/src/i18n/es/collab.ts index c2d2ce1b5..452fd0f57 100644 --- a/shared/src/i18n/es/collab.ts +++ b/shared/src/i18n/es/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Eliminar enlace', 'collab.links.edit': 'Editar enlace', 'collab.links.open': 'Abrir enlace', + 'collab.links.confirmDeleteTitle': '¿Eliminar el enlace?', + 'collab.links.confirmDeleteBody': 'Este enlace se eliminará permanentemente.', 'collab.whatsNext.title': 'Qué viene ahora', 'collab.whatsNext.today': 'Hoy', 'collab.whatsNext.tomorrow': 'Mañana', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 0a7ed9601..a9bff7e0a 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -180,6 +180,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Rechercher uniquement avec Google', 'admin.placesGoogleOnly.subtitle': 'Chaque recherche et chaque suggestion passent par Google Places. Désactivé, l\'index de TREK et OpenStreetMap répondent d\'abord, Google n\'est interrogé que s\'ils ne trouvent rien.', 'admin.placesGoogleOnly.missingKey': 'Nécessite une clé API Google Maps. Sans elle, la recherche passe par l\'index de TREK et OpenStreetMap, quelle que soit la position de cet interrupteur.', + 'admin.placesGoogleOnly.otherProvider': 'Nécessite Google comme fournisseur de lieux. Avec Amap ou OpenStreetMap sélectionné, aucune recherche ne part vers Google, quelle que soit la position de cet interrupteur.', 'admin.transitProvider.title': 'Fournisseur de transports en commun', 'admin.transitProvider.subtitle': 'Quel service répond à la recherche de transports en commun.', 'admin.transitProvider.transitous': 'Transitous (gratuit)', diff --git a/shared/src/i18n/fr/collab.ts b/shared/src/i18n/fr/collab.ts index c37f1063e..bdb7011e3 100644 --- a/shared/src/i18n/fr/collab.ts +++ b/shared/src/i18n/fr/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Supprimer le lien', 'collab.links.edit': 'Modifier le lien', 'collab.links.open': 'Ouvrir le lien', + 'collab.links.confirmDeleteTitle': 'Supprimer le lien ?', + 'collab.links.confirmDeleteBody': 'Ce lien sera définitivement supprimé.', 'collab.whatsNext.title': 'À venir', 'collab.whatsNext.today': "Aujourd'hui", 'collab.whatsNext.tomorrow': 'Demain', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index a7064be6a..a7dbf2bcf 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -226,6 +226,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Αναζήτηση μόνο με Google', 'admin.placesGoogleOnly.subtitle': 'Κάθε αναζήτηση και κάθε πρόταση πηγαίνει στο Google Places. Απενεργοποιημένο, απαντούν πρώτα το ευρετήριο του TREK και το OpenStreetMap, και το Google ρωτιέται μόνο αν δεν βρουν τίποτα.', 'admin.placesGoogleOnly.missingKey': 'Χρειάζεται κλειδί Google Maps API. Χωρίς αυτό η αναζήτηση τρέχει στο ευρετήριο του TREK και στο OpenStreetMap, όπως κι αν είναι ο διακόπτης.', + 'admin.placesGoogleOnly.otherProvider': 'Χρειάζεται το Google ως πάροχο τοποθεσιών. Με επιλεγμένο Amap ή OpenStreetMap καμία αναζήτηση δεν πηγαίνει στο Google, ό,τι κι αν λέει αυτός ο διακόπτης.', 'admin.transitProvider.title': 'Πάροχος μέσων μεταφοράς', 'admin.transitProvider.subtitle': 'Ποια υπηρεσία απαντά στην αναζήτηση μέσων μεταφοράς.', 'admin.transitProvider.transitous': 'Transitous (δωρεάν)', diff --git a/shared/src/i18n/gr/collab.ts b/shared/src/i18n/gr/collab.ts index 7d5688fd8..76e9ea8b5 100644 --- a/shared/src/i18n/gr/collab.ts +++ b/shared/src/i18n/gr/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Διαγραφή συνδέσμου', 'collab.links.edit': 'Επεξεργασία συνδέσμου', 'collab.links.open': 'Άνοιγμα συνδέσμου', + 'collab.links.confirmDeleteTitle': 'Διαγραφή συνδέσμου;', + 'collab.links.confirmDeleteBody': 'Αυτός ο σύνδεσμος θα διαγραφεί οριστικά.', 'collab.whatsNext.title': 'Τι Επόμενο', 'collab.whatsNext.today': 'Σήμερα', 'collab.whatsNext.tomorrow': 'Αύριο', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 64e0637e9..262daf12e 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -180,6 +180,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Keresés csak a Google-lel', 'admin.placesGoogleOnly.subtitle': 'Minden keresés és minden javaslat a Google Places-hez megy. Kikapcsolva előbb a TREK saját indexe és az OpenStreetMap válaszol, a Google-t csak akkor kérdezzük, ha ők nem találnak semmit.', 'admin.placesGoogleOnly.missingKey': 'Google Maps API-kulcs kell hozzá. Nélküle a keresés a TREK indexén és az OpenStreetMapen fut, bárhogy áll is ez a kapcsoló.', + 'admin.placesGoogleOnly.otherProvider': 'A Google-t igényli helyszolgáltatóként. Amap vagy OpenStreetMap kiválasztásával egyetlen keresés sem megy a Google-höz, bárhogy áll is ez a kapcsoló.', 'admin.transitProvider.title': 'Tömegközlekedési szolgáltató', 'admin.transitProvider.subtitle': 'Melyik szolgáltatás válaszol a tömegközlekedési keresésre.', 'admin.transitProvider.transitous': 'Transitous (ingyenes)', diff --git a/shared/src/i18n/hu/collab.ts b/shared/src/i18n/hu/collab.ts index 13b86ea13..6596bb926 100644 --- a/shared/src/i18n/hu/collab.ts +++ b/shared/src/i18n/hu/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Link törlése', 'collab.links.edit': 'Link szerkesztése', 'collab.links.open': 'Link megnyitása', + 'collab.links.confirmDeleteTitle': 'Törlöd a linket?', + 'collab.links.confirmDeleteBody': 'Ez a link véglegesen törlődik.', 'collab.whatsNext.title': 'Mi következik', 'collab.whatsNext.today': 'Ma', 'collab.whatsNext.tomorrow': 'Holnap', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index addb67821..dafd65550 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -222,6 +222,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Cari hanya dengan Google', 'admin.placesGoogleOnly.subtitle': 'Setiap pencarian dan setiap saran dikirim ke Google Places. Nonaktif, indeks TREK dan OpenStreetMap menjawab lebih dulu, dan Google hanya ditanya jika keduanya tidak menemukan apa pun.', 'admin.placesGoogleOnly.missingKey': 'Memerlukan kunci API Google Maps. Tanpa kunci, pencarian memakai indeks TREK dan OpenStreetMap, apa pun posisi sakelar ini.', + 'admin.placesGoogleOnly.otherProvider': 'Membutuhkan Google sebagai penyedia tempat. Dengan Amap atau OpenStreetMap yang dipilih, tidak ada pencarian yang dikirim ke Google, apa pun posisi sakelar ini.', 'admin.transitProvider.title': 'Penyedia transportasi umum', 'admin.transitProvider.subtitle': 'Layanan mana yang menjawab pencarian transportasi umum.', 'admin.transitProvider.transitous': 'Transitous (gratis)', diff --git a/shared/src/i18n/id/collab.ts b/shared/src/i18n/id/collab.ts index 4c2b426ef..28cd555f3 100644 --- a/shared/src/i18n/id/collab.ts +++ b/shared/src/i18n/id/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Hapus tautan', 'collab.links.edit': 'Edit tautan', 'collab.links.open': 'Buka tautan', + 'collab.links.confirmDeleteTitle': 'Hapus tautan?', + 'collab.links.confirmDeleteBody': 'Tautan ini akan dihapus secara permanen.', 'collab.whatsNext.title': 'Berikutnya', 'collab.whatsNext.today': 'Hari ini', 'collab.whatsNext.tomorrow': 'Besok', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 5cd3b6852..538a33b5b 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -178,6 +178,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Cerca solo con Google', 'admin.placesGoogleOnly.subtitle': 'Ogni ricerca e ogni suggerimento vanno a Google Places. Spento, rispondono prima l\'indice di TREK e OpenStreetMap, e Google viene interrogato solo se non trovano nulla.', 'admin.placesGoogleOnly.missingKey': 'Richiede una chiave API di Google Maps. Senza, la ricerca usa l\'indice di TREK e OpenStreetMap, comunque sia impostato questo interruttore.', + 'admin.placesGoogleOnly.otherProvider': 'Richiede Google come provider dei luoghi. Con Amap o OpenStreetMap selezionati, nessuna ricerca va a Google, qualunque sia la posizione di questo interruttore.', 'admin.transitProvider.title': 'Provider del trasporto pubblico', 'admin.transitProvider.subtitle': 'Quale servizio risponde alla ricerca di trasporto pubblico.', 'admin.transitProvider.transitous': 'Transitous (gratuito)', diff --git a/shared/src/i18n/it/collab.ts b/shared/src/i18n/it/collab.ts index a3f588b82..6e307e01a 100644 --- a/shared/src/i18n/it/collab.ts +++ b/shared/src/i18n/it/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Elimina link', 'collab.links.edit': 'Modifica link', 'collab.links.open': 'Apri link', + 'collab.links.confirmDeleteTitle': 'Eliminare il link?', + 'collab.links.confirmDeleteBody': 'Questo link verrà eliminato definitivamente.', 'collab.whatsNext.title': "Cosa c'è dopo", 'collab.whatsNext.today': 'Oggi', 'collab.whatsNext.tomorrow': 'Domani', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index c3fe2f90b..462147f4e 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -211,6 +211,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Google だけで検索', 'admin.placesGoogleOnly.subtitle': 'すべての検索と候補が Google Places に送られます。オフの場合は TREK のインデックスと OpenStreetMap が先に答え、何も見つからないときだけ Google に問い合わせます。', 'admin.placesGoogleOnly.missingKey': 'Google Maps API キーが必要です。キーがない場合、このスイッチに関係なく検索は TREK のインデックスと OpenStreetMap で行われます。', + 'admin.placesGoogleOnly.otherProvider': '場所プロバイダーとして Google が必要です。Amap または OpenStreetMap を選択している間は、このスイッチの状態にかかわらず検索は Google に送られません。', 'admin.transitProvider.title': '公共交通のプロバイダー', 'admin.transitProvider.subtitle': '公共交通の検索に応答するサービス。', 'admin.transitProvider.transitous': 'Transitous(無料)', diff --git a/shared/src/i18n/ja/collab.ts b/shared/src/i18n/ja/collab.ts index bc54cb6f4..57159b6b4 100644 --- a/shared/src/i18n/ja/collab.ts +++ b/shared/src/i18n/ja/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'リンクを削除', 'collab.links.edit': 'リンクを編集', 'collab.links.open': 'リンクを開く', + 'collab.links.confirmDeleteTitle': 'リンクを削除しますか?', + 'collab.links.confirmDeleteBody': 'このリンクは完全に削除されます。', 'collab.whatsNext.title': '次にすること', 'collab.whatsNext.today': '今日', 'collab.whatsNext.tomorrow': '明日', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 343a9916b..e9ae58f5d 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -217,6 +217,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Google로만 검색', 'admin.placesGoogleOnly.subtitle': '모든 검색과 제안이 Google Places로 갑니다. 끄면 TREK 자체 색인과 OpenStreetMap이 먼저 답하고, 둘 다 찾지 못할 때만 Google에 묻습니다.', 'admin.placesGoogleOnly.missingKey': 'Google Maps API 키가 필요합니다. 키가 없으면 이 스위치와 상관없이 TREK 색인과 OpenStreetMap으로 검색합니다.', + 'admin.placesGoogleOnly.otherProvider': '장소 제공자로 Google이 필요합니다. Amap 또는 OpenStreetMap을 선택한 동안에는 이 스위치와 상관없이 검색이 Google로 가지 않습니다.', 'admin.transitProvider.title': '대중교통 제공자', 'admin.transitProvider.subtitle': '대중교통 검색에 응답하는 서비스입니다.', 'admin.transitProvider.transitous': 'Transitous (무료)', diff --git a/shared/src/i18n/ko/collab.ts b/shared/src/i18n/ko/collab.ts index bdc367907..729d06631 100644 --- a/shared/src/i18n/ko/collab.ts +++ b/shared/src/i18n/ko/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': '링크 삭제', 'collab.links.edit': '링크 편집', 'collab.links.open': '링크 열기', + 'collab.links.confirmDeleteTitle': '링크를 삭제할까요?', + 'collab.links.confirmDeleteBody': '이 링크는 영구적으로 삭제됩니다.', 'collab.whatsNext.title': '다음 할 일', 'collab.whatsNext.today': '오늘', 'collab.whatsNext.tomorrow': '내일', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 230842753..d0c37c898 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -179,6 +179,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Alleen met Google zoeken', 'admin.placesGoogleOnly.subtitle': 'Elke zoekopdracht en elke suggestie gaat naar Google Places. Uit, antwoorden eerst de TREK-index en OpenStreetMap; Google wordt alleen gevraagd als die niets vinden.', 'admin.placesGoogleOnly.missingKey': 'Vereist een Google Maps API-sleutel. Zonder sleutel zoekt TREK via de eigen index en OpenStreetMap, wat deze schakelaar ook zegt.', + 'admin.placesGoogleOnly.otherProvider': 'Vereist Google als plaatsenprovider. Met Amap of OpenStreetMap gekozen gaat geen enkele zoekopdracht naar Google, wat deze schakelaar ook zegt.', 'admin.transitProvider.title': 'Ov-provider', 'admin.transitProvider.subtitle': 'Welke dienst het ov-zoeken beantwoordt.', 'admin.transitProvider.transitous': 'Transitous (gratis)', diff --git a/shared/src/i18n/nl/collab.ts b/shared/src/i18n/nl/collab.ts index 1abb00e60..91b63b5b6 100644 --- a/shared/src/i18n/nl/collab.ts +++ b/shared/src/i18n/nl/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Link verwijderen', 'collab.links.edit': 'Link bewerken', 'collab.links.open': 'Link openen', + 'collab.links.confirmDeleteTitle': 'Link verwijderen?', + 'collab.links.confirmDeleteBody': 'Deze link wordt definitief verwijderd.', 'collab.whatsNext.title': 'Wat komt er', 'collab.whatsNext.today': 'Vandaag', 'collab.whatsNext.tomorrow': 'Morgen', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 868435bf3..3eda1722b 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -173,6 +173,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Szukaj tylko przez Google', 'admin.placesGoogleOnly.subtitle': 'Każde wyszukiwanie i każda podpowiedź trafia do Google Places. Wyłączone: najpierw odpowiadają indeks TREK i OpenStreetMap, a Google jest pytany tylko wtedy, gdy nic nie znajdą.', 'admin.placesGoogleOnly.missingKey': 'Wymaga klucza Google Maps API. Bez niego wyszukiwanie działa przez indeks TREK i OpenStreetMap, niezależnie od tego przełącznika.', + 'admin.placesGoogleOnly.otherProvider': 'Wymaga Google jako dostawcy miejsc. Przy wybranym Amap lub OpenStreetMap żadne wyszukiwanie nie trafia do Google, niezależnie od tego przełącznika.', 'admin.transitProvider.title': 'Dostawca transportu publicznego', 'admin.transitProvider.subtitle': 'Która usługa odpowiada na wyszukiwanie transportu publicznego.', 'admin.transitProvider.transitous': 'Transitous (bezpłatnie)', diff --git a/shared/src/i18n/pl/collab.ts b/shared/src/i18n/pl/collab.ts index de4fb3598..3b3174913 100644 --- a/shared/src/i18n/pl/collab.ts +++ b/shared/src/i18n/pl/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Usuń link', 'collab.links.edit': 'Edytuj link', 'collab.links.open': 'Otwórz link', + 'collab.links.confirmDeleteTitle': 'Usunąć link?', + 'collab.links.confirmDeleteBody': 'Ten link zostanie trwale usunięty.', 'collab.whatsNext.title': 'Co dalej', 'collab.whatsNext.today': 'Dzisiaj', 'collab.whatsNext.tomorrow': 'Jutro', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index abc78adae..432093484 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -180,6 +180,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Искать только через Google', 'admin.placesGoogleOnly.subtitle': 'Каждый поиск и каждая подсказка идут в Google Places. Выключено: сначала отвечают индекс TREK и OpenStreetMap, Google спрашивается, только если они ничего не нашли.', 'admin.placesGoogleOnly.missingKey': 'Нужен ключ Google Maps API. Без него поиск идёт через индекс TREK и OpenStreetMap независимо от этого переключателя.', + 'admin.placesGoogleOnly.otherProvider': 'Требуется Google как провайдер мест. Если выбран Amap или OpenStreetMap, ни один поиск не уходит в Google, как бы ни стоял этот переключатель.', 'admin.transitProvider.title': 'Поставщик общественного транспорта', 'admin.transitProvider.subtitle': 'Какая служба отвечает на поиск общественного транспорта.', 'admin.transitProvider.transitous': 'Transitous (бесплатно)', diff --git a/shared/src/i18n/ru/collab.ts b/shared/src/i18n/ru/collab.ts index f7c7c2881..4eb5564e4 100644 --- a/shared/src/i18n/ru/collab.ts +++ b/shared/src/i18n/ru/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Удалить ссылку', 'collab.links.edit': 'Изменить ссылку', 'collab.links.open': 'Открыть ссылку', + 'collab.links.confirmDeleteTitle': 'Удалить ссылку?', + 'collab.links.confirmDeleteBody': 'Эта ссылка будет удалена без возможности восстановления.', 'collab.whatsNext.title': 'Что дальше', 'collab.whatsNext.today': 'Сегодня', 'collab.whatsNext.tomorrow': 'Завтра', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index fe86e6961..14028b584 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -224,6 +224,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Sök bara med Google', 'admin.placesGoogleOnly.subtitle': 'Varje sökning och varje förslag går till Google Places. Av svarar TREK:s eget index och OpenStreetMap först, och Google tillfrågas bara när de inte hittar något.', 'admin.placesGoogleOnly.missingKey': 'Kräver en Google Maps API-nyckel. Utan nyckel söker TREK via sitt eget index och OpenStreetMap, oavsett hur den här brytaren står.', + 'admin.placesGoogleOnly.otherProvider': 'Kräver Google som platsleverantör. Med Amap eller OpenStreetMap valt går ingen sökning till Google, oavsett hur den här väljaren står.', 'admin.transitProvider.title': 'Kollektivtrafikleverantör', 'admin.transitProvider.subtitle': 'Vilken tjänst som besvarar sökningar i kollektivtrafiken.', 'admin.transitProvider.transitous': 'Transitous (gratis)', diff --git a/shared/src/i18n/sv/collab.ts b/shared/src/i18n/sv/collab.ts index bf5badd2c..73a5b6885 100644 --- a/shared/src/i18n/sv/collab.ts +++ b/shared/src/i18n/sv/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Ta bort länk', 'collab.links.edit': 'Redigera länk', 'collab.links.open': 'Öppna länk', + 'collab.links.confirmDeleteTitle': 'Ta bort länken?', + 'collab.links.confirmDeleteBody': 'Den här länken tas bort permanent.', 'collab.whatsNext.title': 'Vad händer härnäst?', 'collab.whatsNext.today': 'Idag', 'collab.whatsNext.tomorrow': 'I morgon', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 596def412..b587fbcdf 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -224,6 +224,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Yalnızca Google ile ara', 'admin.placesGoogleOnly.subtitle': 'Her arama ve her öneri Google Places\'e gider. Kapalıyken önce TREK dizini ve OpenStreetMap yanıtlar, Google yalnızca hiçbir şey bulamadıklarında sorulur.', 'admin.placesGoogleOnly.missingKey': 'Google Maps API anahtarı gerektirir. Anahtar yoksa arama, bu anahtar ne olursa olsun TREK dizini ve OpenStreetMap üzerinden çalışır.', + 'admin.placesGoogleOnly.otherProvider': 'Yer sağlayıcısı olarak Google gerekir. Amap veya OpenStreetMap seçiliyken bu anahtar ne derse desin hiçbir arama Google\'a gitmez.', 'admin.transitProvider.title': 'Toplu taşıma sağlayıcısı', 'admin.transitProvider.subtitle': 'Toplu taşıma aramasını hangi hizmetin yanıtlayacağı.', 'admin.transitProvider.transitous': 'Transitous (ücretsiz)', diff --git a/shared/src/i18n/tr/collab.ts b/shared/src/i18n/tr/collab.ts index 95ad138a2..3cb69011e 100644 --- a/shared/src/i18n/tr/collab.ts +++ b/shared/src/i18n/tr/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Bağlantıyı sil', 'collab.links.edit': 'Bağlantıyı düzenle', 'collab.links.open': 'Bağlantıyı aç', + 'collab.links.confirmDeleteTitle': 'Bağlantı silinsin mi?', + 'collab.links.confirmDeleteBody': 'Bu bağlantı kalıcı olarak silinecek.', 'collab.whatsNext.title': "What's Next", 'collab.whatsNext.today': 'Bugün', 'collab.whatsNext.tomorrow': 'Yarın', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index fd6cbed43..c1dc33de6 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -180,6 +180,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Шукати лише через Google', 'admin.placesGoogleOnly.subtitle': 'Кожен пошук і кожна підказка йдуть до Google Places. Вимкнено: спершу відповідають індекс TREK та OpenStreetMap, Google запитується лише тоді, коли вони нічого не знайшли.', 'admin.placesGoogleOnly.missingKey': 'Потрібен ключ Google Maps API. Без нього пошук іде через індекс TREK та OpenStreetMap незалежно від цього перемикача.', + 'admin.placesGoogleOnly.otherProvider': 'Потрібен Google як постачальник місць. Якщо вибрано Amap або OpenStreetMap, жоден пошук не йде до Google, хай як стоїть цей перемикач.', 'admin.transitProvider.title': 'Постачальник громадського транспорту', 'admin.transitProvider.subtitle': 'Яка служба відповідає на пошук громадського транспорту.', 'admin.transitProvider.transitous': 'Transitous (безкоштовно)', diff --git a/shared/src/i18n/uk/collab.ts b/shared/src/i18n/uk/collab.ts index df9ed1bb0..bb208cee7 100644 --- a/shared/src/i18n/uk/collab.ts +++ b/shared/src/i18n/uk/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Видалити посилання', 'collab.links.edit': 'Редагувати посилання', 'collab.links.open': 'Відкрити посилання', + 'collab.links.confirmDeleteTitle': 'Видалити посилання?', + 'collab.links.confirmDeleteBody': 'Це посилання буде видалено назавжди.', 'collab.whatsNext.title': 'Що далі', 'collab.whatsNext.today': 'Сьогодні', 'collab.whatsNext.tomorrow': 'Завтра', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index fe123c25a..306be5bb7 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -223,6 +223,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': 'Chỉ tìm bằng Google', 'admin.placesGoogleOnly.subtitle': 'Mọi tìm kiếm và mọi gợi ý đều đi tới Google Places. Khi tắt, chỉ mục của TREK và OpenStreetMap trả lời trước, Google chỉ được hỏi khi cả hai không tìm thấy gì.', 'admin.placesGoogleOnly.missingKey': 'Cần khóa API Google Maps. Không có khóa, tìm kiếm chạy trên chỉ mục của TREK và OpenStreetMap, bất kể công tắc này.', + 'admin.placesGoogleOnly.otherProvider': 'Cần Google làm nhà cung cấp địa điểm. Khi chọn Amap hoặc OpenStreetMap, không tìm kiếm nào được gửi đến Google dù công tắc này đặt thế nào.', 'admin.transitProvider.title': 'Nhà cung cấp giao thông công cộng', 'admin.transitProvider.subtitle': 'Dịch vụ nào trả lời tìm kiếm giao thông công cộng.', 'admin.transitProvider.transitous': 'Transitous (miễn phí)', diff --git a/shared/src/i18n/vi/collab.ts b/shared/src/i18n/vi/collab.ts index c1a26a10e..06b47e4dd 100644 --- a/shared/src/i18n/vi/collab.ts +++ b/shared/src/i18n/vi/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': 'Xóa liên kết', 'collab.links.edit': 'Chỉnh sửa liên kết', 'collab.links.open': 'Mở liên kết', + 'collab.links.confirmDeleteTitle': 'Xóa liên kết?', + 'collab.links.confirmDeleteBody': 'Liên kết này sẽ bị xóa vĩnh viễn.', 'collab.whatsNext.title': 'Tiếp theo là gì', 'collab.whatsNext.today': 'Hôm nay', 'collab.whatsNext.tomorrow': 'Ngày mai', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index f94e48953..e655a39c4 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -214,6 +214,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': '僅使用 Google 搜尋', 'admin.placesGoogleOnly.subtitle': '所有搜尋和建議都會送到 Google Places。關閉時,TREK 自有索引和 OpenStreetMap 先回答,只有兩者都沒有結果時才詢問 Google。', 'admin.placesGoogleOnly.missingKey': '需要 Google Maps API 金鑰。沒有金鑰時,無論此開關如何,搜尋都透過 TREK 索引和 OpenStreetMap 進行。', + 'admin.placesGoogleOnly.otherProvider': '需要將 Google 設為地點提供者。選擇 Amap 或 OpenStreetMap 時,無論此開關如何設定,搜尋都不會送往 Google。', 'admin.transitProvider.title': '公共運輸資料來源', 'admin.transitProvider.subtitle': '由哪個服務回應大眾運輸搜尋。', 'admin.transitProvider.transitous': 'Transitous(免費)', diff --git a/shared/src/i18n/zh-TW/collab.ts b/shared/src/i18n/zh-TW/collab.ts index e146932ff..9a8a80a7e 100644 --- a/shared/src/i18n/zh-TW/collab.ts +++ b/shared/src/i18n/zh-TW/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': '刪除連結', 'collab.links.edit': '編輯連結', 'collab.links.open': '開啟連結', + 'collab.links.confirmDeleteTitle': '刪除連結?', + 'collab.links.confirmDeleteBody': '此連結將被永久刪除。', 'collab.whatsNext.title': '接下來', 'collab.whatsNext.today': '今天', 'collab.whatsNext.tomorrow': '明天', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 9a9fb0074..6fba359d6 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -175,6 +175,7 @@ const admin: TranslationStrings = { 'admin.placesGoogleOnly.title': '仅使用 Google 搜索', 'admin.placesGoogleOnly.subtitle': '所有搜索和建议都发送到 Google Places。关闭时,TREK 自有索引和 OpenStreetMap 先回答,只有二者都没有结果时才询问 Google。', 'admin.placesGoogleOnly.missingKey': '需要 Google Maps API 密钥。没有密钥时,无论此开关如何,搜索都通过 TREK 索引和 OpenStreetMap 进行。', + 'admin.placesGoogleOnly.otherProvider': '需要将 Google 设为地点提供方。选择 Amap 或 OpenStreetMap 时,无论此开关如何设置,搜索都不会发送到 Google。', 'admin.transitProvider.title': '公共交通数据源', 'admin.transitProvider.subtitle': '由哪个服务响应公共交通搜索。', 'admin.transitProvider.transitous': 'Transitous(免费)', diff --git a/shared/src/i18n/zh/collab.ts b/shared/src/i18n/zh/collab.ts index adbf3de50..69e1985c9 100644 --- a/shared/src/i18n/zh/collab.ts +++ b/shared/src/i18n/zh/collab.ts @@ -25,6 +25,8 @@ const collab: TranslationStrings = { 'collab.links.delete': '删除链接', 'collab.links.edit': '编辑链接', 'collab.links.open': '打开链接', + 'collab.links.confirmDeleteTitle': '删除链接?', + 'collab.links.confirmDeleteBody': '此链接将被永久删除。', 'collab.whatsNext.title': '接下来', 'collab.whatsNext.today': '今天', 'collab.whatsNext.tomorrow': '明天', diff --git a/shared/src/maps/maps.schema.ts b/shared/src/maps/maps.schema.ts index af821f8ad..15cb626c4 100644 --- a/shared/src/maps/maps.schema.ts +++ b/shared/src/maps/maps.schema.ts @@ -30,7 +30,9 @@ export const mapsSearchRequestSchema = z.object({ * Ask one provider alone for this search. The index and OpenStreetMap answer * first by default and Google is only asked when they find nothing; a caller * whose results were not the place they meant can send the same query to - * Google instead. Ignored on an instance where Google holds no key. + * Google instead. Ignored unless Google holds the keyed slot: without a + * Google key, or with Amap or OpenStreetMap picked as the places provider, + * the index and OpenStreetMap answer as usual. */ provider: z.enum(['google']).optional(), }); diff --git a/shared/src/roadtrip/carriers.spec.ts b/shared/src/roadtrip/carriers.spec.ts index d42ad05ef..5c3718263 100644 --- a/shared/src/roadtrip/carriers.spec.ts +++ b/shared/src/roadtrip/carriers.spec.ts @@ -1,5 +1,5 @@ /** - * ROADTRIP-CARRIERS-001..019: a booking the traveller rides becomes a seam in the drive, + * ROADTRIP-CARRIERS-001..021: a booking the traveller rides becomes a seam in the drive, * and a hire car puts its desks on it. * * The road ends at the terminal the ride leaves from and starts again at the one it @@ -15,11 +15,9 @@ import { carrierLeg, carrierLegsFor, carrierReservationIds, - carrierRideMinutes, carrierSeam, carriesTheCar, isCarrierMode, - isCarrierStop, isPickupStop, seatCarrierStops, terminalAssignmentId, @@ -196,7 +194,6 @@ describe('a hire car', () => { )!; expect(noDesk.arrival).toBeNull(); expect(carrierSeam(rental({ endpoints: [] }))).toBeNull(); - expect(carrierRideMinutes(noDay, 0)).toBeNull(); const seated = seatCarrierStops(1, [stop({ ownerIndex: 0, time: '10:00' })], [0], [noDay]); expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 0']); expect(seatCarrierStops(3, [stop({ ownerIndex: 0 })], [0], [noDay]).map((s) => s.carrier?.role ?? s.name)).toEqual([ @@ -222,7 +219,6 @@ describe('a hire car', () => { incomingLegMode: null, }); expect(pickup.assignmentId).toBe(terminalAssignmentId(9, 'pickup')); - expect(isCarrierStop(pickup)).toBe(true); expect(isPickupStop(pickup)).toBe(true); // No ride leaves a pick-up desk: the leg out of it is a road like any other. expect(carrierLegsFor([pickup, first[1]!], 'driving', () => 0, roadtripLegKey)).toBeNull(); @@ -283,19 +279,52 @@ describe('a hire car', () => { expect(day.driveWarnings.some((w) => w.code === 'range')).toBe(false); expect(routes.totalStops).toBe(3); }); + + it('ROADTRIP-CARRIERS-021: a hire car whose pick-up desk is not located stands nowhere, the return desk alone is no pick-up', () => { + // The form and the booking import both drop an endpoint that did not geocode, so a + // one-way hire can arrive with its return desk only. Reading that desk as the pick-up + // started the drive in the city the car is handed back in. + const returnOnly = rental({ + endpoints: [{ role: 'to', sequence: 1, name: 'Sixt Airport', code: 'HAM', lat: 53.63, lng: 9.99 }], + }); + expect(carrierSeam(returnOnly)).toBeNull(); + const plannedStopOnly = rental({ + endpoints: [ + { role: 'stop', sequence: 1, name: 'Luebeck', code: null, lat: 53.87, lng: 10.69 }, + { role: 'to', sequence: 2, name: 'Sixt Airport', code: 'HAM', lat: 53.63, lng: 9.99 }, + ], + }); + expect(carrierSeam(plannedStopOnly)).toBeNull(); + // A ride still takes the first located stop for its departure when none is marked. + const unmarked = flight({ + endpoints: [ + { role: 'stop', sequence: 0, name: 'Munich Airport', code: 'MUC', lat: 48.35, lng: 11.78 }, + { role: 'to', sequence: 1, name: 'Hamburg Airport', code: 'HAM', lat: 53.63, lng: 9.99 }, + ], + }); + expect(carrierSeam(unmarked)!.departure.code).toBe('MUC'); + }); }); -describe('carrierRideMinutes', () => { +describe('the minutes of a ride', () => { + const rideOf = (booking: CarrierBooking, dayDelta: number): RoutedLeg => { + const seam = carrierSeam(booking)!; + const [dep] = seatCarrierStops(seam.departure.dayId, [], [], [seam]) as [RoadtripStop]; + const arr = seatCarrierStops(seam.arrival!.dayId, [], [], [seam]).find((s) => s.carrier?.role === 'arrival')!; + return carrierLegsFor([dep, arr], 'flight', () => dayDelta, roadtripLegKey)![roadtripLegKey(dep, arr)]!; + }; + it('ROADTRIP-CARRIERS-007: the ride is what the clocks say, across the days the booking spans, and never negative', () => { - const same = carrierSeam(flight())!; - expect(carrierRideMinutes(same, 0)).toBe(70); - const overnight = carrierSeam(flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 }))!; - expect(carrierRideMinutes(overnight, 1)).toBe(540); + expect(rideOf(flight(), 0).seg.duration).toBe(70 * 60); + const overnight = flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 }); + expect(rideOf(overnight, 1).seg.duration).toBe(540 * 60); // Westward across the date line the clock lands before it left; the chain cannot // run backwards, so the ride counts as none rather than as a day. - const dateLine = carrierSeam(flight({ reservation_time: '10:00', reservation_end_time: '06:00' }))!; - expect(carrierRideMinutes(dateLine, 0)).toBe(0); - expect(carrierRideMinutes(carrierSeam(flight({ reservation_end_time: null }))!, 0)).toBeNull(); + expect(rideOf(flight({ reservation_time: '10:00', reservation_end_time: '06:00' }), 0).seg.duration).toBe(0); + // Without an arrival clock there are no minutes to give, and the leg says so. + const unclocked = rideOf(flight({ reservation_end_time: null }), 0); + expect(unclocked.seg.duration).toBe(0); + expect(unclocked.seg.durationText).toBe(''); }); }); @@ -339,8 +368,6 @@ describe('seatCarrierStops', () => { expect(dep.placeId).toBeLessThan(0); expect(dep.ownerIndex).toBe(2); expect(arr.ownerIndex).toBe(2); - expect(isCarrierStop(dep)).toBe(true); - expect(isCarrierStop(day[0])).toBe(false); }); it('ROADTRIP-CARRIERS-009: a dragged position decides among untimed stops, and a ride before every timed stop opens the day', () => { @@ -530,4 +557,64 @@ describe('the ride in the drive', () => { expect(chainTwo.schedule.entries[0]).toMatchObject({ arrival: '07:00' }); expect(chainTwo.stops.some((s) => s.automaticNight)).toBe(false); }); + + it('ROADTRIP-CARRIERS-020: a red-eye inside its check-in allowance is pinned at midnight on its own day, and the day stays on one card', () => { + // 00:30 less an hour is 23:30, which the clock wraps onto the evening before: the + // chain then read the flight as leaving the next day and filed the arrival and every + // stop after it on the next card, with a spill mark on a day nothing spilled from. + const redEye = carrierSeam(flight({ reservation_time: '00:30', reservation_end_time: '02:30' }))!; + const seated = seatCarrierStops( + 1, + [ + stop({ ownerIndex: 0, time: '09:00', dwellMinutes: 0 }), + stop({ ownerIndex: 1, time: '14:00', dwellMinutes: 0 }), + ], + [0, 1], + [redEye], + ); + expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual(['departure', 'arrival', 'Stop 0', 'Stop 1']); + const [dep, arr, first, second] = seated as [RoadtripStop, RoadtripStop, RoadtripStop, RoadtripStop]; + expect(dep).toMatchObject({ time: '00:00', leaveAt: '00:30', dwellMinutes: 30 }); + expect(arr.time).toBe('02:30'); + + const nextDay = [ + stop({ ownerIndex: 0, ownerDayId: 2, time: '10:00', dwellMinutes: 0, lat: 53.7 }), + stop({ ownerIndex: 1, ownerDayId: 2, time: '12:00', dwellMinutes: 0, lat: 53.9 }), + ]; + const legs: Record = { + [roadtripLegKey(arr, first)]: road(30), + [roadtripLegKey(first, second)]: road(30), + [roadtripLegKey(nextDay[0]!, nextDay[1]!)]: road(30), + ...carrierLegsFor([dep, arr], 'flight', () => 0, roadtripLegKey)!, + }; + const routes = assembleRoadtrip({ + plan: [ + { dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops: seated }, + { dayId: 2, dayNumber: 2, date: '2026-06-02', title: null, stops: nextDay }, + ], + quietDays: [], + window: null, + distanceUnit: 'metric', + allLegs: legs, + snapByDay: {}, + missedByDay: {}, + loading: false, + limits: { legMinutes: null, dayMinutes: null, rangeKm: null }, + vehicleKind: 'combustion', + connectDays: false, + boundaries: [], + labels: { start: 'go', end: 'stop' }, + }); + expect(routes.days.map((d) => d.stops.map((s) => s.carrier?.role ?? s.name))).toEqual([ + ['departure', 'arrival', 'Stop 0', 'Stop 1'], + ['Stop 0', 'Stop 1'], + ]); + expect(routes.days[0]!.schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([ + ['00:00', '00:30'], + ['02:30', '02:30'], + ['09:00', '09:00'], + ['14:00', '14:00'], + ]); + expect(routes.days.some((d) => d.spills?.length)).toBe(false); + }); }); diff --git a/shared/src/roadtrip/carriers.ts b/shared/src/roadtrip/carriers.ts index b7f703078..348446a4a 100644 --- a/shared/src/roadtrip/carriers.ts +++ b/shared/src/roadtrip/carriers.ts @@ -169,11 +169,13 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null { (e) => typeof e.lat === 'number' && typeof e.lng === 'number' && Number.isFinite(e.lat) && Number.isFinite(e.lng), ) .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)); - const from = located.find((e) => e.role === 'from') ?? located[0]; // A ride needs both ends to say where the drive stops and where it resumes, and takes - // the last located stop for its arrival when no endpoint is marked as one. A hire car's - // return is only ever the desk marked as such: guessing one would put a stop on the - // road nobody booked. The same desk twice is fine for a car, and no seam for a ride. + // the first and the last located stop for them when no endpoint is marked. A hire car's + // desks are only ever the ones marked as such: guessing one would put a stop on the + // road nobody booked, and a return desk read as the pick-up starts the drive where it + // ends. The same desk twice is fine for a car, and no seam for a ride. + const from = + kind === 'ride' ? (located.find((e) => e.role === 'from') ?? located[0]) : located.find((e) => e.role === 'from'); const to = kind === 'ride' ? ([...located].reverse().find((e) => e.role === 'to') ?? located[located.length - 1]) @@ -225,16 +227,14 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null { } /** - * Minutes of the ride as the clocks tell them, across the days the booking spans. + * Minutes of a ride as the clocks tell them, across the days it spans. * * Wall clocks on purpose, not elapsed time: the chain runs on the local clock, and after * landing the day goes on in the destination's time. A ride that lands earlier on the * clock than it left (westward across the date line) cannot be told from a ride of no * length this way and counts as one. Null when either clock is missing. */ -export function carrierRideMinutes(seam: CarrierSeam, dayDelta: number): number | null { - const dep = parseClock(seam.departure.clock); - const arr = parseClock(seam.arrival?.clock); +function rideMinutes(dep: number | null, arr: number | null, dayDelta: number): number | null { if (dep === null || arr === null) return null; return Math.max(0, arr + Math.max(0, dayDelta) * 1440 - dep); } @@ -251,11 +251,6 @@ function rolesOf(seam: CarrierSeam): { start: CarrierTerminal['role']; end: Carr return seam.kind === 'rental' ? { start: 'pickup', end: 'return' } : { start: 'departure', end: 'arrival' }; } -/** Whether a stop is a carrier terminal, which is what every writer has to refuse. */ -export function isCarrierStop(stop: Pick | null | undefined): boolean { - return !!stop?.carrier; -} - function terminalStop( seam: CarrierSeam, role: CarrierTerminal['role'], @@ -269,8 +264,10 @@ function terminalStop( // The departure terminal is pinned a check-in ahead of the timetable and left at the // timetable's minute; the arrival one is pinned at the timetable's minute and left at // once. Neither has a stay of its own. A hire car's desks are pinned at the booking's - // own clock, and the drive goes on from them without a stay. - const time = role === 'departure' && depart !== null ? formatClock(depart - checkIn) : end.clock; + // own clock, and the drive goes on from them without a stay. The pin never goes past + // midnight: a red-eye inside its allowance would otherwise read as the evening before, + // and the chain would file every stop after it on the next day's card. + const pinnedAt = role === 'departure' && depart !== null ? Math.max(0, depart - checkIn) : null; return { carrier: { reservationId: seam.reservationId, @@ -287,9 +284,9 @@ function terminalStop( name: end.name, lat: end.lat, lng: end.lng, - time, + time: pinnedAt === null ? end.clock : formatClock(pinnedAt), leaveAt: role === 'departure' ? end.clock : null, - dwellMinutes: role === 'departure' && depart !== null ? checkIn : 0, + dwellMinutes: depart !== null && pinnedAt !== null ? depart - pinnedAt : 0, checkInTime: null, night: false, endDay: false, @@ -458,12 +455,7 @@ export function carrierLegsFor( for (let i = 0; i < run.length - 1; i++) { const from = run[i]!; const to = run[i + 1]!; - const dep = parseClock(from.carrier?.at); - const arr = parseClock(to.carrier?.at); - const minutes = - from.carrier && to.carrier && dep !== null && arr !== null - ? Math.max(0, arr + Math.max(0, dayDelta(from, to)) * 1440 - dep) - : null; + const minutes = rideMinutes(parseClock(from.carrier?.at), parseClock(to.carrier?.at), dayDelta(from, to)); legs[key(from, to)] = carrierLeg(from, to, minutes); } return legs; diff --git a/shared/src/roadtrip/corridor.spec.ts b/shared/src/roadtrip/corridor.spec.ts index 31adf93a6..c40b0f307 100644 --- a/shared/src/roadtrip/corridor.spec.ts +++ b/shared/src/roadtrip/corridor.spec.ts @@ -2,13 +2,18 @@ import { boxAround, corridorTiles, distanceToSegmentKm, + drivenPieces, haversineKm, + inRiddenRange, pointAtMeters, projectOntoRoute, + rideGaps, + riddenRanges, sliceAtMeters, simplifyLine, type LatLng, } from './corridor'; +import type { RoadtripStop } from './planning-types'; import { describe, it, expect } from 'vitest'; @@ -283,3 +288,108 @@ describe('sliceAtMeters', () => { expect(out.every((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng))).toBe(true); }); }); + +describe('a ride in the corridor (#2428)', () => { + // A drive north along one meridian, a flight on to the next terminal, and a short drive + // after it: a degree of latitude is about 111 km, so every figure reads off the line. + const ORIGIN: LatLng = { lat: 52, lng: 13 }; + const DEPARTURE: LatLng = { lat: 53, lng: 13 }; + const ARRIVAL: LatLng = { lat: 55, lng: 13 }; + const HOTEL: LatLng = { lat: 55.5, lng: 13 }; + const spine = [ORIGIN, DEPARTURE, ARRIVAL, HOTEL]; + const underTheFlight: LatLng = { lat: 54, lng: 13 }; + + const terminal = ( + role: 'departure' | 'arrival' | 'pickup' | 'return', + at: LatLng, + reservationId = 7, + ): Pick => ({ + carrier: { + reservationId, + type: role === 'pickup' || role === 'return' ? 'car' : 'flight', + role, + title: '', + code: null, + at: null, + }, + ...at, + }); + + it('a departure followed by the arrival of its own booking is a gap, at the departure index', () => { + const stops = [ORIGIN, terminal('departure', DEPARTURE), terminal('arrival', ARRIVAL), HOTEL]; + expect(rideGaps(stops)).toEqual([{ index: 1, from: DEPARTURE, to: ARRIVAL }]); + }); + + it('a lone departure, another booking’s arrival and a hire car’s desks make no gap', () => { + // Landing tomorrow, the arrival is on another day; the drive between a return desk + // and a pick-up desk IS driven, whatever booking the two belong to. + expect(rideGaps([ORIGIN, terminal('departure', DEPARTURE)])).toEqual([]); + expect(rideGaps([terminal('departure', DEPARTURE, 7), terminal('arrival', ARRIVAL, 8)])).toEqual([]); + expect(rideGaps([terminal('pickup', DEPARTURE), terminal('return', ARRIVAL)])).toEqual([]); + expect(rideGaps([])).toEqual([]); + }); + + it('projects both terminals onto the spine and keeps the stretches in order', () => { + const [ride] = riddenRanges(spine, [{ from: DEPARTURE, to: ARRIVAL }]); + expect(ride!.fromKm).toBeCloseTo(111, -1); + expect(ride!.toKm).toBeCloseTo(333, -1); + // Handed in backwards, or twice over, the stretches still read from the start. + const two = riddenRanges(spine, [ + { from: ARRIVAL, to: HOTEL }, + { from: ARRIVAL, to: DEPARTURE }, + ]); + expect(two.map((r) => Math.round(r.fromKm))).toEqual([111, 334]); + // A ride the spine cannot place, or whose ends land on the same point, is no stretch. + expect(riddenRanges([ORIGIN], [{ from: DEPARTURE, to: ARRIVAL }])).toEqual([]); + expect(riddenRanges(spine, [{ from: DEPARTURE, to: DEPARTURE }])).toEqual([]); + }); + + it('a hit under the flight is inside the ride, the terminals themselves are not', () => { + const ridden = riddenRanges(spine, [{ from: DEPARTURE, to: ARRIVAL }]); + const along = (p: LatLng): number => projectOntoRoute(p, spine)!.alongKm; + expect(inRiddenRange(ridden, along(underTheFlight))).toBe(true); + expect(inRiddenRange(ridden, along(DEPARTURE))).toBe(false); + expect(inRiddenRange(ridden, along(ARRIVAL))).toBe(false); + expect(inRiddenRange(ridden, along(ORIGIN))).toBe(false); + expect(inRiddenRange([], along(underTheFlight))).toBe(false); + }); + + it('cuts the line into the driven pieces, and no box is asked for under the flight', () => { + const ridden = riddenRanges(spine, [{ from: DEPARTURE, to: ARRIVAL }]); + const pieces = drivenPieces(spine, ridden); + expect(pieces).toHaveLength(2); + expect(pieces[0]![0]).toEqual(ORIGIN); + expect(pieces[0]![pieces[0]!.length - 1]!.lat).toBeCloseTo(DEPARTURE.lat, 3); + expect(pieces[1]![0]!.lat).toBeCloseTo(ARRIVAL.lat, 3); + expect(pieces[1]![pieces[1]!.length - 1]).toEqual(HOTEL); + + const covers = (tiles: ReturnType, p: LatLng): boolean => + tiles.some((t) => t.south <= p.lat && t.north >= p.lat && t.west <= p.lng && t.east >= p.lng); + const tiles = pieces.flatMap((piece) => corridorTiles(piece, 5)); + expect(covers(tiles, underTheFlight)).toBe(false); + expect(covers(tiles, { lat: 52.5, lng: 13 })).toBe(true); + // The same line straight through is what the search asked for before. + expect(covers(corridorTiles(spine, 5), underTheFlight)).toBe(true); + }); + + it('without a ride the whole line is the one piece, and a window is a slice of it', () => { + expect(drivenPieces(spine, [])).toEqual([spine]); + expect(drivenPieces(spine, [])[0]).toBe(spine); + const [windowed] = drivenPieces(spine, [], { fromKm: 0, toKm: 50 }); + expect(windowed![windowed!.length - 1]!.lat).toBeCloseTo(52.45, 1); + expect(drivenPieces([ORIGIN], [])).toEqual([]); + }); + + it('a window and a ride together leave the driven part of the window only', () => { + const ridden = riddenRanges(spine, [{ from: DEPARTURE, to: ARRIVAL }]); + const pieces = drivenPieces(spine, ridden, { fromKm: 100, toKm: 400 }); + expect(pieces).toHaveLength(2); + expect(pieces[0]![0]!.lat).toBeCloseTo(52.9, 1); + expect(pieces[0]![pieces[0]!.length - 1]!.lat).toBeCloseTo(53, 2); + expect(pieces[1]![0]!.lat).toBeCloseTo(55, 2); + expect(pieces[1]![pieces[1]!.length - 1]).toEqual(HOTEL); + // A window that ends before the ride never reaches it; one lying inside it is empty. + expect(drivenPieces(spine, ridden, { fromKm: 0, toKm: 50 })).toHaveLength(1); + expect(drivenPieces(spine, ridden, { fromKm: 150, toKm: 300 })).toEqual([]); + }); +}); diff --git a/shared/src/roadtrip/corridor.ts b/shared/src/roadtrip/corridor.ts index eb922c163..f44bc2616 100644 --- a/shared/src/roadtrip/corridor.ts +++ b/shared/src/roadtrip/corridor.ts @@ -1,3 +1,5 @@ +import type { RoadtripStop } from './planning-types'; + export interface LatLng { lat: number; lng: number; @@ -224,3 +226,83 @@ export function boxAround(point: LatLng, radiusKm: number): Bbox { east: point.lng + dLng, }; } + +/** A stretch of the spine, in kilometres along it. */ +export interface KmRange { + fromKm: number; + toKm: number; +} + +/** A ride among a day's stops: the departure terminal, at `index`, and the arrival it jumps to. */ +export interface RideGap { + index: number; + from: LatLng; + to: LatLng; +} + +/** + * The rides on a day (#2428): each departure terminal followed by the arrival of the same + * booking. The line runs straight from the one to the other there, and the car is not on + * it, so a corridor search leaves the stretch out and drops what it finds under it. + */ +export function rideGaps(stops: readonly Pick[]): RideGap[] { + const out: RideGap[] = []; + stops.forEach((stop, i) => { + const next = stops[i + 1]; + if (stop.carrier?.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId) { + out.push({ index: i, from: { lat: stop.lat, lng: stop.lng }, to: { lat: next.lat, lng: next.lng } }); + } + }); + return out; +} + +/** + * The ridden stretches as kilometres along the spine, in order. A ride whose two ends + * land on the same point of the spine, or that has no spine to be measured against, is + * no stretch. A terminal well off the line still projects onto its nearest point, so a + * ride is never dropped for standing away from the road. + */ +export function riddenRanges(spine: LatLng[], gaps: readonly { from: LatLng; to: LatLng }[]): KmRange[] { + const out: KmRange[] = []; + for (const gap of gaps) { + const a = projectOntoRoute(gap.from, spine)?.alongKm; + const b = projectOntoRoute(gap.to, spine)?.alongKm; + if (a === undefined || b === undefined) continue; + const range = { fromKm: Math.min(a, b), toKm: Math.max(a, b) }; + if (range.toKm > range.fromKm) out.push(range); + } + return out.sort((a, b) => a.fromKm - b.fromKm); +} + +// The terminals themselves stand at the ends of a ride and stay on the road. +const TERMINAL_SLACK_KM = 0.05; + +export function inRiddenRange(ridden: readonly KmRange[], alongKm: number): boolean { + return ridden.some((r) => alongKm > r.fromKm + TERMINAL_SLACK_KM && alongKm < r.toKm - TERMINAL_SLACK_KM); +} + +/** + * The spine cut into the pieces the car drives: the window, or the whole line, with every + * ridden stretch left out. Only the pieces to tile are cut, never the spine itself: every + * alongKm is a distance along the spine, and cutting that would renumber every hit and + * every stop the moment a window moved. + */ +export function drivenPieces(spine: LatLng[], ridden: readonly KmRange[], window?: KmRange | null): LatLng[][] { + const start = window?.fromKm ?? 0; + const end = window?.toKm ?? Number.POSITIVE_INFINITY; + const slice = (fromKm: number, toKm: number): LatLng[] => + sliceAtMeters(spine, fromKm * 1000, Number.isFinite(toKm) ? toKm * 1000 : Number.MAX_SAFE_INTEGER); + const pieces: LatLng[][] = []; + if (!ridden.length) { + pieces.push(window ? slice(start, end) : spine); + } else { + let cursor = start; + for (const range of ridden) { + if (range.fromKm >= end) break; + if (range.fromKm > cursor) pieces.push(slice(cursor, range.fromKm)); + cursor = Math.max(cursor, range.toKm); + } + if (cursor < end) pieces.push(slice(cursor, end)); + } + return pieces.filter((piece) => piece.length > 1); +} From 85b5756e898c78a350180e87cd511306dbb19726 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 22 Sep 2026 12:10:21 +0200 Subject: [PATCH 3/4] fix(client): the audit round over the 4.3.1 release Ride minutes and late marks follow an edited timetable instead of the routing that fetched them; a landing day's join band reads as the ride it is rather than a nine hour drive of no length; the refuel offer is there on the road out of a terminal or a pick-up desk; the phone's stage map draws the ride; the share page numbers its stops the way the planner does; the PDF lists a day the way the day plan lists it, from both shells; a poster-less clip draws its play badge everywhere, not only in the editor; the hero title has room for its descenders and the list card keeps its badge clear; the release notice keeps its close control in reach when it stacks; a link chip's actions are thumb-sized and its delete asks first; the Google retry line and the Google-only switch appear only where Google answers. --- client/src/App.tsx | 7 +- client/src/api/client.ts | 8 +- .../components/Collab/CollabLinks.test.tsx | 65 +++++- client/src/components/Collab/CollabLinks.tsx | 18 +- .../JourneyDetailPageEntryEditor.test.tsx | 28 ++- .../Journey/JourneyDetailPageEntryEditor.tsx | 37 +++- .../Journey/JourneyEntryCover.test.tsx | 21 +- .../components/Journey/JourneyEntryCover.tsx | 24 ++- .../Journey/MobileEntryView.test.tsx | 23 ++- .../components/Journey/MobileEntryView.tsx | 33 +-- .../components/Journey/VideoPlayer.test.tsx | 27 ++- client/src/components/Journey/VideoPlayer.tsx | 34 ++-- client/src/components/PDF/TripPDF.test.ts | 35 +++- client/src/components/PDF/TripPDF.tsx | 41 +++- .../Planner/PlaceFormModal.test.tsx | 75 +++++++ .../src/components/Planner/PlaceFormModal.tsx | 27 ++- .../Planner/PlacesSidebarHeader.tsx | 6 +- .../Planner/TripExportModal.test.tsx | 3 +- .../components/Planner/TripExportModal.tsx | 6 +- .../Roadtrip/RoadtripSidebar.test.tsx | 148 +++++++++++--- .../components/Roadtrip/RoadtripSidebar.tsx | 192 +++++++++--------- client/src/components/Roadtrip/carrierRide.ts | 19 +- client/src/components/Roadtrip/corridor.ts | 4 +- .../Roadtrip/roadtripRowModel.test.ts | 46 ++++- .../components/Roadtrip/roadtripRowModel.ts | 21 +- .../components/Roadtrip/stopBookings.test.ts | 17 ++ .../src/components/Roadtrip/stopBookings.ts | 21 +- .../Roadtrip/useCorridorPois.test.ts | 16 ++ .../components/Roadtrip/useCorridorPois.ts | 43 +--- .../Roadtrip/useRoadtripCorridor.test.ts | 33 +++ .../Roadtrip/useRoadtripCorridor.ts | 24 +-- .../Roadtrip/useRoadtripRoutes.test.ts | 43 ++++ .../components/Roadtrip/useRoadtripRoutes.ts | 98 ++++++--- .../SystemNotices/ReleaseNoticeModal.test.tsx | 13 +- .../SystemNotices/ReleaseNoticeModal.tsx | 14 +- .../SystemNotices/releaseNotice.css | 71 +++++-- .../SystemNotices/releaseNoticeCss.test.ts | 160 ++++++++++++++- client/src/index.css | 7 +- .../screens/admin/MAdminSettingsSection.tsx | 3 +- .../mobile/screens/journey/MJourneyDetail.tsx | 4 +- .../screens/journey/MJourneyEntrySheet.tsx | 28 ++- .../src/mobile/screens/trip/map/MMapArea.tsx | 6 +- .../screens/trip/roadtrip/MRoadtripRows.tsx | 13 +- .../screens/trip/roadtrip/MRoadtripTab.tsx | 23 ++- .../screens/trip/sheets/MExportSheet.tsx | 7 +- .../screens/trip/sheets/PlPlaceSearch.tsx | 14 +- client/src/pages/JourneyPublicPage.test.tsx | 40 +++- client/src/pages/JourneyPublicPage.tsx | 108 ++++++---- .../pages/JourneyPublicPage.wiring.test.tsx | 5 +- client/src/pages/SharedTripPage.test.tsx | 55 ++++- client/src/pages/SharedTripPage.tsx | 33 ++- client/src/pages/TripPlannerPage.test.tsx | 88 +++++++- client/src/pages/TripPlannerPage.tsx | 1 + .../src/pages/admin/AdminSettingsTab.test.tsx | 10 + client/src/pages/admin/AdminSettingsTab.tsx | 8 +- client/src/pages/admin/GoogleOptions.tsx | 4 +- client/src/pages/admin/useAdmin.test.tsx | 34 +++- client/src/pages/admin/useAdmin.ts | 20 +- .../pages/journeyPublic/journeyPublicModel.ts | 6 + .../pages/journeyPublic/useJourneyPublic.ts | 9 +- .../useTripPlanner.roadtrip.test.tsx | 68 ++++++- .../src/pages/tripPlanner/useTripPlanner.ts | 2 +- client/src/store/authStore.ts | 7 + client/src/store/slices/remoteEventHandler.ts | 34 ++-- client/src/styles/dashboard.css | 32 ++- client/src/styles/dashboardCss.test.ts | 90 +++++++- client/src/utils/placeSource.test.ts | 35 +++- client/src/utils/placeSource.ts | 61 ++++-- client/tests/helpers/mobileTrip.ts | 1 + .../admin/MAdminSettingsSection.test.tsx | 29 +++ .../mobile/journey/MJourneyDetail.test.tsx | 11 +- .../journey/MJourneyEntrySheet.test.tsx | 26 +++ .../unit/mobile/trip/MExportSheet.test.tsx | 4 + .../tests/unit/mobile/trip/MMapArea.test.tsx | 35 +++- .../unit/mobile/trip/MRoadtripRows.test.tsx | 147 +++++++++++++- .../unit/mobile/trip/MRoadtripTab.test.tsx | 134 +++++++++++- .../unit/mobile/trip/PlPlaceSearch.test.tsx | 88 +++++++- .../remoteEventHandler/assignments.test.ts | 131 +++++++++++- 78 files changed, 2476 insertions(+), 486 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index e4c72ab8e..80bbf931f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -284,7 +284,7 @@ function RouteFallback() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() const { loadAddons } = useAddonStore() const { loadPlugins } = usePluginStore() @@ -301,7 +301,7 @@ export default function App() { loadUser() } } - authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record }) => { + authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record }) => { setManaged(!!config?.managed) setDemoMode(!!config?.demo_mode) if (config?.dev_mode) setDevMode(true) @@ -359,6 +359,9 @@ export default function App() { return } } + // Last, so the version block above keeps its lines: the taint analyser + // re-raises its browser-storage finding for every line that moves under it. + if (config?.places_provider) setPlacesProvider(config.places_provider) }).catch(() => {}) }, []) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4d093ba64..d338a41a2 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1216,11 +1216,11 @@ export const mapsApi = { * caller of this one function gets them without knowing they exist. Appended rather * than interleaved: the core list is ordered by relevance and has earned that order, * and a plugin's row carries its own `source` for a caller that wants to mark it. - */ - /** + * * `provider: 'google'` sends this one search to Google alone, the "search Google - * instead" link under a list the index answered with the wrong place. The - * server ignores it on an instance without a Google key. + * instead" link under a list the index answered with the wrong place. The server + * ignores it unless Google holds the keyed slot: without a Google key, or with + * Amap or OpenStreetMap picked as the provider, the index answers as usual. */ search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') => withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => { diff --git a/client/src/components/Collab/CollabLinks.test.tsx b/client/src/components/Collab/CollabLinks.test.tsx index 4b2d7e7c3..779fd3cdb 100644 --- a/client/src/components/Collab/CollabLinks.test.tsx +++ b/client/src/components/Collab/CollabLinks.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-LINKS-001 to FE-COMP-LINKS-008 +// FE-COMP-LINKS-001 to FE-COMP-LINKS-013 vi.mock('../../api/websocket', () => ({ connect: vi.fn(), @@ -10,7 +10,9 @@ vi.mock('../../api/websocket', () => ({ removeListener: vi.fn(), })); -import { render, screen, waitFor } from '../../../tests/helpers/render'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; @@ -160,6 +162,65 @@ describe('CollabLinks', () => { expect(screen.getByText('ferries.example')).toBeInTheDocument(); }); + it('FE-COMP-LINKS-011: deleting asks first, and the DELETE goes out only once confirmed', async () => { + // The link goes for every member, and on the phone the button is a thumb's + // width from pin: the notes panel guards the same way. + const user = userEvent.setup(); + let deleted = false; + server.use( + http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })), + http.delete('/api/trips/1/collab/links/1', () => { deleted = true; return HttpResponse.json({ success: true }); }), + ); + render(); + await user.click(await screen.findByRole('button', { name: /delete link|collab\.links\.delete/i })); + expect(await screen.findByText(/delete link\?|collab\.links\.confirmDeleteTitle/i)).toBeInTheDocument(); + expect(deleted).toBe(false); + expect(screen.getByText('Ferry timetable')).toBeInTheDocument(); + + await user.click(document.querySelector('button.bg-red-600') as HTMLElement); + await waitFor(() => expect(deleted).toBe(true)); + await waitFor(() => expect(screen.queryByText('Ferry timetable')).not.toBeInTheDocument()); + }); + + it('FE-COMP-LINKS-012: a corrected address gets a fresh favicon attempt', async () => { + // The icon remembers a favicon that failed, and an edit keeps the chip's id, + // so the corrected address kept the generic glyph until the panel remounted. + const user = userEvent.setup(); + server.use( + http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink({ url: 'https://old.example/page' })] })), + http.put('/api/trips/1/collab/links/1', () => HttpResponse.json({ link: buildLink({ url: 'https://new.example/page' }) })), + ); + render(); + const chip = await screen.findByRole('link', { name: /ferry timetable/i }); + fireEvent.error(chip.querySelector('img') as HTMLImageElement); + await waitFor(() => expect(chip.querySelector('img')).toBeNull()); + + await user.click(screen.getByRole('button', { name: /edit link|collab\.links\.edit/i })); + const url = await screen.findByLabelText(/https|collab\.links\.urlPlaceholder/i); + await user.clear(url); + await user.type(url, 'https://new.example/page'); + await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i })); + + await waitFor(() => expect(screen.getByRole('link', { name: /ferry timetable/i }).querySelector('img')) + .toHaveAttribute('src', 'https://new.example/favicon.ico')); + }); + + it('FE-COMP-LINKS-013: on touch the chip actions are finger-sized and apart', () => { + // jsdom lays nothing out, so the rule is the assertion. The phone Collab tab + // mounts this chip; 22px buttons a pixel apart put delete one slip from pin. + const css = readFileSync(resolve(process.cwd(), 'src/index.css'), 'utf8'); + const at = css.indexOf('@media (hover: none) {\n .collab-link-chip__actions'); + expect(at, 'touch rules for the chip actions missing from index.css').toBeGreaterThan(-1); + const touch = css.slice(at, css.indexOf('\n}', at)); + expect(touch).toMatch(/\.collab-link-chip__actions \{[^}]*gap:\s*4px/); + const action = /\.collab-link-chip__action \{([^}]*)\}/.exec(touch)?.[1] ?? ''; + for (const side of ['width', 'height']) { + expect(Number(new RegExp(`${side}:\\s*(\\d+)px`).exec(action)?.[1]), side).toBeGreaterThanOrEqual(24); + } + // The height comes back through the margin, so the chip itself stays 36px. + expect(action).toMatch(/margin:\s*-4px 0/); + }); + it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => { // collab_edit reserved for the owner, on somebody else's trip. seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } }); diff --git a/client/src/components/Collab/CollabLinks.tsx b/client/src/components/Collab/CollabLinks.tsx index a9fb3f217..fbe688143 100644 --- a/client/src/components/Collab/CollabLinks.tsx +++ b/client/src/components/Collab/CollabLinks.tsx @@ -6,6 +6,7 @@ import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' +import ConfirmDialog from '../shared/ConfirmDialog' import EmptyState from '../shared/EmptyState' import { useToast } from '../shared/Toast' @@ -111,6 +112,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) { const [links, setLinks] = useState([]) const [showForm, setShowForm] = useState(false) const [editing, setEditing] = useState(null) + const [pendingDeleteId, setPendingDeleteId] = useState(null) // Both are new objects on every render, so depending on them would reload the // list after each keystroke and drop a link that was just added back out of it. @@ -230,7 +232,9 @@ export default function CollabLinks({ tripId }: { tripId: number }) { {links.map(link => (
- + {/* Keyed on the address: the icon remembers a favicon that failed, and + an edit keeps the chip's id, so a corrected address got the glyph. */} + {link.title} {hostOf(link.url)} @@ -244,7 +248,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) { - @@ -258,6 +262,16 @@ export default function CollabLinks({ tripId }: { tripId: number }) { {/* Create and edit modals */} {showForm && setShowForm(false)} onSave={add} />} {editing && setEditing(null)} onSave={data => edit(editing, data)} />} + + {/* The delete is shared: the link goes for every member, and on the phone the + button is a thumb's width from pin. Same guard as the notes panel. */} + setPendingDeleteId(null)} + onConfirm={() => (pendingDeleteId !== null ? remove(pendingDeleteId) : undefined)} + title={t('collab.links.confirmDeleteTitle')} + message={t('collab.links.confirmDeleteBody')} + />
) } diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx index 7aa6c6ab5..124f88f5f 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx @@ -1,4 +1,4 @@ -// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-040 +// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-059 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { http, HttpResponse, delay } from 'msw' @@ -1045,4 +1045,30 @@ describe('EntryEditor', () => { expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument() expect(container.querySelector('svg.lucide-play')).not.toBeInTheDocument() }) + + it('FE-JRN-EDITOR-058: the gallery picker gives a clip without a poster the same play badge (#2341)', async () => { + const user = userEvent.setup() + const clip = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: null } + const { container } = mountEditor(buildEntry(), { galleryPhotos: [clip] }) + + await user.click(screen.getByRole('button', { name: 'From Gallery' })) + + expect(container.querySelector('img[src="/api/photos/200/thumbnail"]')).not.toBeInTheDocument() + expect(container.querySelector('svg.lucide-play')).toBeInTheDocument() + }) + + it('FE-JRN-EDITOR-059: a clip whose poster fails to load is not retried as the clip itself', async () => { + // The /original of a video is the video file, which an cannot draw. + const user = userEvent.setup() + const strip = { ...buildPhoto(100), media_type: 'video', provider: 'local', thumbnail_path: 'journey/a.jpg' } + const picker = { ...buildGalleryPhoto(200), media_type: 'video', provider: 'local', thumbnail_path: 'journey/b.jpg' } + const { container } = mountEditor(buildEntry({ id: 10, photos: [strip] }), { galleryPhotos: [picker] }) + + await user.click(screen.getByRole('button', { name: 'From Gallery' })) + for (const id of [100, 200]) { + const img = container.querySelector(`img[src="/api/photos/${id}/thumbnail"]`) as HTMLImageElement + fireEvent.error(img) + expect(img.getAttribute('src')).toBe(`/api/photos/${id}/thumbnail`) + } + }) }) diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx index 5818a5d66..8a73cbe92 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useRef } from 'react' +import { useEffect, useMemo, useState, useRef, type SyntheticEvent } from 'react' import { localIsoDate } from '../../utils/localDate' import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera, Play } from 'lucide-react' import { normalizeImageFiles } from '../../utils/convertHeic' @@ -21,6 +21,27 @@ import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourn type PendingProviderGroup = ProviderPhotoGroup & { provider: string } +// A photo whose thumbnail is missing can still be drawn from its original. A +// clip's original is the video file, which no can show, so a clip gets +// no fallback (#2341). +function thumbnailFallback(p: { photo_id: number; media_type?: string | null }) { + if (p.media_type === 'video') return undefined + return (e: SyntheticEvent) => { + const img = e.currentTarget + if (!img.src.includes('/original')) img.src = photoUrl(p, 'original') + } +} + +// No poster to show and falling back to /original would hand an the clip +// itself, so this tile stays a play badge. +function ClipTile() { + return ( +
+ +
+ ) +} + export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, showVerdict = true, showMood = true, showWeather = true, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: { entry: JourneyEntry journeyId: number @@ -434,7 +455,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, className="relative block w-full rounded-xl overflow-hidden border-0 p-0 bg-transparent cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all" style={{ paddingTop: '100%' }} > - { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} /> + {posterlessVideo(gp) ? ( + + ) : ( + + )} ))} {availableGalleryPhotos.length === 0 && ( @@ -528,13 +553,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, {photos.map((p, idx) => (
1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}> {posterlessVideo(p) ? ( - // No poster to show and falling back to /original would hand - // an the clip itself, so this tile stays a play badge. -
- -
+ ) : ( - { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} /> + )} {idx === 0 && photos.length > 1 && ( {t('journey.editor.photoFirst')} diff --git a/client/src/components/Journey/JourneyEntryCover.test.tsx b/client/src/components/Journey/JourneyEntryCover.test.tsx index 994b66580..5b746454a 100644 --- a/client/src/components/Journey/JourneyEntryCover.test.tsx +++ b/client/src/components/Journey/JourneyEntryCover.test.tsx @@ -1,4 +1,4 @@ -// FE-JRN-COVER-001 to FE-JRN-COVER-008 +// FE-JRN-COVER-001 to FE-JRN-COVER-010 import { describe, it, expect, vi } from 'vitest' import userEvent from '@testing-library/user-event' @@ -112,4 +112,23 @@ describe('JourneyEntryCover', () => { expect(onClick).toHaveBeenCalledOnce() }) + + it('FE-JRN-COVER-009: a clip without a poster falls back to the ground, marked as a clip (#2341)', () => { + // Its thumbnail route 404s on purpose, so an would have been a broken face + // across the whole card. + const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: null }] }) + const { container } = render( {}} />) + + expect(container.querySelector('img[src*="/thumbnail"]')).toBeNull() + expect(container.querySelector('svg.lucide-play')).toBeInTheDocument() + expect(container.querySelector('svg.lucide-map-pin')).toBeNull() + }) + + it('FE-JRN-COVER-010: a clip with its poster fills the card like any photo', () => { + const entry = buildEntry({ photos: [{ photo_id: 7, media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }] }) + const { container } = render( {}} />) + + expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail') + expect(container.querySelector('svg.lucide-play')).toBeNull() + }) }) diff --git a/client/src/components/Journey/JourneyEntryCover.tsx b/client/src/components/Journey/JourneyEntryCover.tsx index 8a4069b7c..acdd73a78 100644 --- a/client/src/components/Journey/JourneyEntryCover.tsx +++ b/client/src/components/Journey/JourneyEntryCover.tsx @@ -1,6 +1,7 @@ -import { MapPin, Plus } from 'lucide-react' +import { MapPin, Play, Plus } from 'lucide-react' import { useTranslation } from '../../i18n' import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta' +import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import CountryFlag from '../shared/CountryFlag' import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard' import type { JourneyEntry } from '../../store/journeyStore' @@ -25,6 +26,13 @@ import type { JourneyEntry } from '../../store/journeyStore' * which needs no tokens at all. */ +/** A card's photo, plus what says whether a clip has a poster to draw (#2341). */ +export interface CoverPhoto extends CardPhoto { + media_type?: string | null + provider?: string | null + thumbnail_path?: string | null +} + /** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */ export interface CoverEntry { id: number @@ -39,7 +47,7 @@ export interface CoverEntry { entry_time?: string | null mood?: string | null weather?: string | null - photos?: CardPhoto[] + photos?: CoverPhoto[] } interface Props { @@ -69,8 +77,12 @@ export default function JourneyEntryCover({ const { t, locale } = useTranslation() const isSuggestion = entry.type === 'skeleton' - const photoId = cardPhotoId(entry.photos?.[0]) - const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail` + const first = entry.photos?.[0] + const photoId = cardPhotoId(first) + // A clip without a poster has no picture to fill the card with; its thumbnail + // route 404s and an pointed at it would draw the broken glyph (#2341). + const posterless = first != null && posterlessVideo(first) + const src = photoId == null || posterless ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail` const title = cardTitle(entry, t) const place = cardPlace(entry) @@ -112,7 +124,9 @@ export default function JourneyEntryCover({ ) : ( - + {posterless + ? + : } )} diff --git a/client/src/components/Journey/MobileEntryView.test.tsx b/client/src/components/Journey/MobileEntryView.test.tsx index c32288f93..d1eb3a2fc 100644 --- a/client/src/components/Journey/MobileEntryView.test.tsx +++ b/client/src/components/Journey/MobileEntryView.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-017 +// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020 import { render, screen, fireEvent } from '../../../tests/helpers/render' import { resetAllStores, seedStore } from '../../../tests/helpers/store' @@ -234,4 +234,25 @@ describe('MobileEntryView', () => { expect(screen.queryByText('Amazing')).not.toBeInTheDocument() expect(screen.queryByText('Sunny')).not.toBeInTheDocument() }) + + it('FE-COMP-JENTRYVIEW-019: a clip is drawn from its poster, in the hero as in the strip (#2341)', () => { + // Its original is the video file, which an cannot show, so the hero used + // to be a broken image for every clip, poster or not. + const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' } + const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] })) + const images = container.querySelectorAll('img') + expect(images[0]).toHaveAttribute('src', '/api/photos/4/thumbnail') + expect(images[1]).toHaveAttribute('src', '/api/photos/4/thumbnail') + expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2) + }) + + it('FE-COMP-JENTRYVIEW-020: a clip without a poster keeps the play badge and asks for no image at all', () => { + const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: null } + const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] })) + const images = container.querySelectorAll('img') + // The second photo's strip thumbnail is the only image left. + expect(images).toHaveLength(1) + expect(images[0]).toHaveAttribute('src', '/api/photos/5/thumbnail') + expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2) + }) }) diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx index e4ee45705..a6cad6306 100644 --- a/client/src/components/Journey/MobileEntryView.tsx +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -7,6 +7,7 @@ import { import JournalBody from './JournalBody' import { useTranslation } from '../../i18n' import { formatLocationName } from '../../utils/formatters' +import { posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' // Labels are translation keys, like the desktop tables in @@ -105,12 +106,18 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo }`} onClick={() => onPhotoClick(photos, 0)} > - + {/* A clip is drawn from its poster, its original being the video file. + Without a poster the black ground and the play badge are the hero (#2341). */} + {posterlessVideo(photos[0]) ? ( + + ) : ( + + )} {photos[0].media_type === 'video' && (
@@ -140,12 +147,14 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo }`} onClick={() => onPhotoClick(photos, i)} > - + {!posterlessVideo(p) && ( + + )} {p.media_type === 'video' && (
diff --git a/client/src/components/Journey/VideoPlayer.test.tsx b/client/src/components/Journey/VideoPlayer.test.tsx index 738f2db4e..00a5fc999 100644 --- a/client/src/components/Journey/VideoPlayer.test.tsx +++ b/client/src/components/Journey/VideoPlayer.test.tsx @@ -3,10 +3,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const plyrCtor = vi.fn() vi.mock('plyr', () => ({ default: class { + el: HTMLElement + original: Node constructor(el: HTMLElement, options: Record) { plyrCtor(el, options) + this.el = el + this.original = el.cloneNode(true) + } + // What the real destroy() does to the document: the element as it was at + // construction goes back in, whatever React wrote to it since. + destroy() { + this.el.replaceWith(this.original) } - destroy = vi.fn() }, })) vi.mock('plyr/dist/plyr.css', () => ({})) @@ -31,7 +39,7 @@ describe('VideoPlayer', () => { it('resets the element to an empty source on teardown rather than a clip on the CDN', () => { // media-src refuses cdn.plyr.io too; an empty source aborts the stream just the - // same, and the element is unmounted in the same tick. + // same, and the element is out of the document by then. render() const [, options] = plyrCtor.mock.calls[0] @@ -45,4 +53,19 @@ describe('VideoPlayer', () => { expect(el).toBe(container.querySelector('video')) expect(container.querySelector('video')).toHaveAttribute('poster', '/api/photos/7/thumbnail') }) + + it('stepping to another clip builds the player on a fresh element, so the clip on screen is the new one', () => { + // Rebuilding Plyr on the same