mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
fix(atlas): stop counting a connection airport as a country visited
The role IN ('from','to') filter from #1486 is correct and still in place, but it
cannot see the case this issue actually reports. Joining AirTrail legs into one
booking with a layover stop is opt-in, so legs imported separately store the hub
as the legitimate `to` of one booking and the `from` of the next, which no role
filter can tell apart from two real arrivals.
Pair them where the countries are counted instead: an arrival and a departure at
the same airport, in different bookings of the same trip, within 24 h of each
other are a connection, and neither endpoint counts as reached. 24 h is the same
window the importer already uses to chain same-PNR legs. Doing it on the read side
means the databases that already hold these flights heal on the next request.
Only flights pair, and never cancelled ones, so a rental car picked up at the
airport still counts its country (#1366). A booking that returns to where the
chain started is a return flight, and that check compares distance rather than
airport identity, so flying home into a different airport of the same metro area
does not erase the country you spent the day in. A country with a place logged in
it, or marked by hand, is untouched.
Closes #1535
This commit is contained in:
@@ -14,8 +14,19 @@ import {
|
||||
} from './atlas-geo';
|
||||
import { KNOWN_COUNTRIES } from './known-countries';
|
||||
import { cityFromAddress } from './city-from-address';
|
||||
import { transferEndpointIds } from './transfer-endpoints';
|
||||
import type { FlightEndpointRow } from './transfer-endpoints';
|
||||
import { haversineKm } from '../common/geo';
|
||||
|
||||
/**
|
||||
* A reservation endpoint plus the two booking columns that decide whether it may
|
||||
* take part in layover pairing at all (see transfer-endpoints.ts).
|
||||
*/
|
||||
type EndpointRow = FlightEndpointRow & {
|
||||
reservation_type: string | null;
|
||||
reservation_status: string | null;
|
||||
};
|
||||
|
||||
export type CreateBucketData = {
|
||||
name: string;
|
||||
lat?: number | null;
|
||||
@@ -314,19 +325,33 @@ export class AtlasService {
|
||||
// via resolvePlaceCountries above and would otherwise be missed (#1366).
|
||||
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
|
||||
// connection/layover (e.g. a plane change) the traveler never really visited.
|
||||
const endpoints = this.db
|
||||
const endpointRows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT DISTINCT r.trip_id, e.lat, e.lng
|
||||
SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time,
|
||||
r.type AS reservation_type, r.status AS reservation_status,
|
||||
CASE e.role
|
||||
WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time)
|
||||
ELSE COALESCE(r.reservation_time, r.reservation_end_time)
|
||||
END AS fallback_time
|
||||
FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
WHERE r.trip_id IN (${tripIds.map(() => '?').join(',')}) AND e.role IN ('from', 'to')
|
||||
`,
|
||||
)
|
||||
.all(...tripIds) as { trip_id: number; lat: number; lng: number }[];
|
||||
.all(...tripIds) as EndpointRow[];
|
||||
|
||||
// Selecting trip_id makes the DISTINCT per trip, so the same airport comes back once
|
||||
// per booking. Collapse to one entry per coordinate before resolving countries —
|
||||
// A layover the traveler never left is only marked 'stop' while both its legs sit in
|
||||
// one booking. Split over two bookings it is a plain 'to' plus 'from' at the same
|
||||
// airport, so it has to be recognised from the times instead (#1535). Only flights
|
||||
// pair: a rental car picked up where a flight landed has its own from/to there and
|
||||
// must keep contributing its country (#1366).
|
||||
const transfers = transferEndpointIds(
|
||||
endpointRows.filter((e) => e.reservation_type === 'flight' && e.reservation_status !== 'cancelled'),
|
||||
);
|
||||
const endpoints = endpointRows.filter((e) => !transfers.has(e.id));
|
||||
|
||||
// Collapse to one entry per coordinate before resolving countries —
|
||||
// getCountryFromCoords is a point-in-polygon scan and used to run once per point.
|
||||
const endpointStatus = new Map<string, { lat: number; lng: number; status: VisitStatus }>();
|
||||
for (const e of endpoints) {
|
||||
@@ -962,9 +987,16 @@ export class AtlasService {
|
||||
// planned place there) was never counted as visited (#1366). Resolve each endpoint
|
||||
// coordinate to a country and fold it in too.
|
||||
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
|
||||
// connection/layover (e.g. a plane change) the traveler never really visited (#1486).
|
||||
const endpoints = this.db.all<{ lat: number; lng: number }>(`
|
||||
SELECT DISTINCT e.lat, e.lng
|
||||
// connection/layover (e.g. a plane change) the traveler never really visited (#1486),
|
||||
// and the same layover split over two bookings is recognised from its ground time
|
||||
// instead, because there it is stored as a legitimate 'to'/'from' pair (#1535).
|
||||
const endpointRows = this.db.all<EndpointRow>(`
|
||||
SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time,
|
||||
r.type AS reservation_type, r.status AS reservation_status,
|
||||
CASE e.role
|
||||
WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time)
|
||||
ELSE COALESCE(r.reservation_time, r.reservation_end_time)
|
||||
END AS fallback_time
|
||||
FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
JOIN trips t ON r.trip_id = t.id
|
||||
@@ -973,7 +1005,18 @@ export class AtlasService {
|
||||
AND COALESCE(t.start_date, t.end_date) IS NOT NULL
|
||||
AND COALESCE(t.start_date, t.end_date) <= date('now')
|
||||
`, userId, userId);
|
||||
for (const e of endpoints) {
|
||||
const transfers = transferEndpointIds(
|
||||
endpointRows.filter(e => e.reservation_type === 'flight' && e.reservation_status !== 'cancelled')
|
||||
);
|
||||
// The DISTINCT no longer collapses per coordinate now that the endpoint id has to be
|
||||
// in the projection, so collapse here instead: getCountryFromCoords is a
|
||||
// point-in-polygon scan and must stay at one run per coordinate.
|
||||
const endpointCoords = new Map<string, { lat: number; lng: number }>();
|
||||
for (const e of endpointRows) {
|
||||
if (transfers.has(e.id)) continue;
|
||||
endpointCoords.set(`${e.lat},${e.lng}`, { lat: e.lat, lng: e.lng });
|
||||
}
|
||||
for (const e of endpointCoords.values()) {
|
||||
const code = getCountryFromCoords(e.lat, e.lng);
|
||||
if (code) countryCodes.add(code.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Layover detection for the two country-deriving Atlas queries.
|
||||
*
|
||||
* A plane change is not a visit, so #1486 taught both queries to skip
|
||||
* reservation_endpoints rows with role='stop'. That only reaches a layover
|
||||
* stored INSIDE one booking. When the legs of one journey land in the database as
|
||||
* two separate reservations (an AirTrail import whose flights never chained, or
|
||||
* legs imported one by one before the join feature existed), the hub is the
|
||||
* legitimate 'to' of the first booking and the legitimate 'from' of the second,
|
||||
* and no role filter can tell that apart from a real destination (#1535).
|
||||
*
|
||||
* What does tell it apart is the time on the ground: leaving the same airport
|
||||
* again within 24 h is a connection, not a stay. The window is deliberately the
|
||||
* one both importers already apply when they merge the legs of one journey
|
||||
* (orderConnectionChain in airtrail-import.service.ts, kitinerary-mapper.ts), so
|
||||
* Atlas and the importers agree on what counts as one connection.
|
||||
*
|
||||
* Read-side only, hence no migration: both queries derive their countries live on
|
||||
* every request, so a database that already holds the split bookings heals as
|
||||
* soon as this ships.
|
||||
*/
|
||||
import { haversineKm } from '../common/geo';
|
||||
|
||||
/** Ground time up to which an onward departure is a connection rather than a stay. */
|
||||
export const MAX_LAYOVER_MS = 24 * 3600 * 1000;
|
||||
|
||||
/**
|
||||
* Without clocks only calendar dates are left. Leaving the day after arriving is
|
||||
* still an overnight connection; a wider gap can't be told apart from a genuine
|
||||
* stopover in the stored data, so it keeps counting as visited.
|
||||
*/
|
||||
export const MAX_CLOCKLESS_DAY_GAP = 1;
|
||||
|
||||
/**
|
||||
* How far from where the arriving leg started the departing leg may land and
|
||||
* still count as the flight home rather than the next hop. A return is regularly
|
||||
* booked into the sibling airport of the same city (Charleroi instead of
|
||||
* Zaventem, Gatwick instead of Heathrow), and that day was spent at the
|
||||
* destination, not in transit.
|
||||
*
|
||||
* A radius is the right shape for that question: the country of a coordinate
|
||||
* would be both too coarse (Belfast to Dublin to London would read as a return
|
||||
* and start counting Ireland as visited) and too expensive, since
|
||||
* getCountryFromCoords scans admin polygons and this runs inside the pairing
|
||||
* loop. 100 km covers every sibling pair worth naming (CRL/BRU 45 km, LGW/LHR
|
||||
* 40 km, EWR/JFK 34 km, HND/NRT 60 km) and stays below the distance between two
|
||||
* cities that each own their traffic, so Amsterdam to Frankfurt to Brussels
|
||||
* (170 km) keeps pairing as the connection it is.
|
||||
*/
|
||||
const RETURN_RADIUS_KM = 100;
|
||||
|
||||
/** One reservation_endpoints row, joined to the columns its booking contributes. */
|
||||
export interface FlightEndpointRow {
|
||||
id: number;
|
||||
reservation_id: number;
|
||||
trip_id: number;
|
||||
role: string;
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
local_date: string | null;
|
||||
local_time: string | null;
|
||||
/** The booking's own time column for this side of the leg, used for the date only. */
|
||||
fallback_time: string | null;
|
||||
}
|
||||
|
||||
/** Local calendar date plus, when the leg carried an instant, the local clock. */
|
||||
interface Stamp {
|
||||
date: string | null;
|
||||
time: string | null;
|
||||
}
|
||||
|
||||
/** Where one end of a booking actually is, for the airports the importer geocoded. */
|
||||
interface Coords {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
/** The match keys and positions of one booking's own two ends, shared by all of its endpoints. */
|
||||
interface BookingKeys {
|
||||
from: Set<string>;
|
||||
to: Set<string>;
|
||||
fromCoords: Coords[];
|
||||
toCoords: Coords[];
|
||||
}
|
||||
|
||||
interface Endpoint {
|
||||
row: FlightEndpointRow;
|
||||
booking: BookingKeys;
|
||||
/** The buckets this endpoint belongs to, already scoped to its trip. */
|
||||
keys: string[];
|
||||
/** Midnight of the leg's local date. */
|
||||
dayMs: number;
|
||||
/** The leg's exact instant, null when it carried no clock. */
|
||||
exactMs: number | null;
|
||||
/** What a bucket of departures is ordered by: the instant if there is one, else the day. */
|
||||
sortMs: number;
|
||||
}
|
||||
|
||||
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})/;
|
||||
const CLOCK = /^\d{2}:\d{2}/;
|
||||
const DAY_MS = 24 * 3600 * 1000;
|
||||
|
||||
/**
|
||||
* How far past the arrival's midnight the sorted departures still have to be
|
||||
* walked. Two days, because each half of a pair is anchored on its own date: a
|
||||
* clockless arrival still pairs with a departure at 23:45 the next day, and an
|
||||
* arrival at 23:50 still pairs with a date-only departure the day after. This
|
||||
* bounds the scan only, isConnection stays the one place that decides.
|
||||
*/
|
||||
const SCAN_WINDOW_MS = 2 * DAY_MS;
|
||||
|
||||
/**
|
||||
* Every key an endpoint can be matched on. Two rows for one airport need not
|
||||
* agree on their shape: an imported leg carries the IATA/ICAO code, a booking
|
||||
* typed in by hand may only have been geocoded. Indexing both and pairing on
|
||||
* either match covers the mixed case, and the coordinate key is rounded because
|
||||
* two geocodes of the same airport differ in the last decimals.
|
||||
*/
|
||||
function endpointKeys(e: FlightEndpointRow): string[] {
|
||||
const keys: string[] = [];
|
||||
const code = e.code?.trim();
|
||||
if (code) keys.push(`c:${code.toUpperCase()}`);
|
||||
if (Number.isFinite(e.lat) && Number.isFinite(e.lng)) keys.push(`p:${e.lat.toFixed(2)},${e.lng.toFixed(2)}`);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* An endpoint only has local_date/local_time when its leg carried a usable
|
||||
* instant; localParts() returns nulls otherwise, which is exactly the date-only
|
||||
* AirTrail flight that fails to chain in the first place. So fall back to the
|
||||
* booking's own time column, but take its DATE half only: for an arrival that
|
||||
* column can hold the departure airport's local clock (airtrail.mapper.ts), which
|
||||
* says nothing about the time on the ground here.
|
||||
*/
|
||||
function stampOf(e: FlightEndpointRow): Stamp {
|
||||
if (e.local_date && DATE_ONLY.test(e.local_date)) {
|
||||
const time = e.local_time && CLOCK.test(e.local_time) ? e.local_time.slice(0, 5) : null;
|
||||
return { date: e.local_date, time };
|
||||
}
|
||||
const fallback = e.fallback_time?.match(DATE_PREFIX);
|
||||
return { date: fallback ? fallback[1] : null, time: null };
|
||||
}
|
||||
|
||||
function utcMs(date: string, time: string | null): number {
|
||||
return Date.parse(`${date}T${time ?? '00:00'}:00Z`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instants the pairing compares, read once per endpoint instead of once per
|
||||
* pair. null means the leg can never be either half of a pair: without a date
|
||||
* that parses, neither branch below has anything to measure.
|
||||
*
|
||||
* A clock that got past the shape check can still be nonsense, and the resulting
|
||||
* NaN is kept rather than downgraded to "no clock": a pair where both sides claim
|
||||
* an instant has always been judged on the instant alone.
|
||||
*/
|
||||
function instantsOf(stamp: Stamp): { dayMs: number; exactMs: number | null; sortMs: number } | null {
|
||||
if (!stamp.date) return null;
|
||||
const dayMs = utcMs(stamp.date, null);
|
||||
if (!Number.isFinite(dayMs)) return null;
|
||||
const exactMs = stamp.time === null ? null : utcMs(stamp.date, stamp.time);
|
||||
return { dayMs, exactMs, sortMs: exactMs !== null && Number.isFinite(exactMs) ? exactMs : dayMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the departure the onward leg of the arrival? Both endpoints sit at the same
|
||||
* airport and therefore in the same timezone, so the naive local stamps compare
|
||||
* as real instants and their difference is the true time on the ground. That is
|
||||
* the only reason this is sound, so never reuse it across two airports.
|
||||
*/
|
||||
function isConnection(arrival: Endpoint, departure: Endpoint): boolean {
|
||||
if (arrival.exactMs !== null && departure.exactMs !== null) {
|
||||
const gap = departure.exactMs - arrival.exactMs;
|
||||
return Number.isFinite(gap) && gap >= 0 && gap <= MAX_LAYOVER_MS;
|
||||
}
|
||||
const days = (departure.dayMs - arrival.dayMs) / DAY_MS;
|
||||
return days >= 0 && days <= MAX_CLOCKLESS_DAY_GAP;
|
||||
}
|
||||
|
||||
function intersects(a: Set<string>, b: Set<string>): boolean {
|
||||
for (const key of a) {
|
||||
if (b.has(key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the departing booking heading back to where the arriving one started? Then
|
||||
* the traveler spent the time in between at the destination, and the same call is
|
||||
* the one orderConnectionChain makes before it joins two legs.
|
||||
*
|
||||
* The airport keys answer that for a return into the exact same airport; the
|
||||
* distance covers the sibling airport of the same city, which the keys can never
|
||||
* match. Legs the importer had no coordinates for fall back to the key match.
|
||||
*/
|
||||
function returnsToOrigin(departure: Endpoint, arrival: Endpoint): boolean {
|
||||
if (intersects(departure.booking.to, arrival.booking.from)) return true;
|
||||
for (const landed of departure.booking.toCoords) {
|
||||
for (const started of arrival.booking.fromCoords) {
|
||||
if (haversineKm(landed.lat, landed.lng, started.lat, started.lng) <= RETURN_RADIUS_KM) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** First entry of a bucket sorted by sortMs that is not earlier than `from`. */
|
||||
function firstFrom(bucket: Endpoint[], from: number): number {
|
||||
let low = 0;
|
||||
let high = bucket.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (bucket[mid].sortMs < from) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint ids that are only a connection airport, spread over two bookings.
|
||||
*
|
||||
* Callers pass flight endpoints of non-cancelled bookings only: a car rental
|
||||
* picked up right where a flight landed has its own from/to at that airport and
|
||||
* would otherwise pair with the arrival and swallow the destination country
|
||||
* (#1366).
|
||||
*/
|
||||
export function transferEndpointIds(rows: FlightEndpointRow[]): Set<number> {
|
||||
const transfers = new Set<number>();
|
||||
if (rows.length < 2) return transfers;
|
||||
|
||||
const landings: Endpoint[] = [];
|
||||
const departures = new Map<string, Endpoint[]>();
|
||||
const bookings = new Map<number, BookingKeys>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.role !== 'from' && row.role !== 'to') continue;
|
||||
const keys = endpointKeys(row);
|
||||
if (keys.length === 0) continue;
|
||||
|
||||
let booking = bookings.get(row.reservation_id);
|
||||
if (!booking) {
|
||||
booking = { from: new Set<string>(), to: new Set<string>(), fromCoords: [], toCoords: [] };
|
||||
bookings.set(row.reservation_id, booking);
|
||||
}
|
||||
const arriving = row.role === 'to';
|
||||
const ownKeys = arriving ? booking.to : booking.from;
|
||||
for (const key of keys) ownKeys.add(key);
|
||||
if (Number.isFinite(row.lat) && Number.isFinite(row.lng)) {
|
||||
(arriving ? booking.toCoords : booking.fromCoords).push({ lat: row.lat, lng: row.lng });
|
||||
}
|
||||
|
||||
// A leg without a readable date pairs with nothing, but its keys and its
|
||||
// position had to be recorded above: they describe the booking either way.
|
||||
const instants = instantsOf(stampOf(row));
|
||||
if (!instants) continue;
|
||||
|
||||
// Two trips that happen to touch the same airport are two journeys: an
|
||||
// arrival home from one and a departure on the next is not a layover. The
|
||||
// trip belongs in the bucket key rather than in a per-pair check, so the
|
||||
// journeys of an imported flight log are never compared against each other.
|
||||
const endpoint: Endpoint = { row, booking, keys: keys.map((key) => `${row.trip_id}:${key}`), ...instants };
|
||||
if (arriving) {
|
||||
landings.push(endpoint);
|
||||
continue;
|
||||
}
|
||||
for (const key of endpoint.keys) {
|
||||
const bucket = departures.get(key);
|
||||
if (bucket) bucket.push(endpoint);
|
||||
else departures.set(key, [endpoint]);
|
||||
}
|
||||
}
|
||||
|
||||
// In time order, so an arrival can stop looking once the ground time is out of
|
||||
// reach. Ten years of flight log imported into one trip put hundreds of
|
||||
// departures on a home airport, and comparing every arrival against all of them
|
||||
// cost more than a second on both request paths that call this.
|
||||
for (const bucket of departures.values()) bucket.sort((a, b) => a.sortMs - b.sortMs);
|
||||
|
||||
for (const arrival of landings) {
|
||||
const until = arrival.dayMs + SCAN_WINDOW_MS;
|
||||
// An endpoint carrying both a code and a coordinate sits in two buckets, so
|
||||
// without this the same pair would be judged twice.
|
||||
const seen = arrival.keys.length > 1 ? new Set<Endpoint>() : null;
|
||||
for (const key of arrival.keys) {
|
||||
const left = departures.get(key);
|
||||
if (!left) continue;
|
||||
for (let i = firstFrom(left, arrival.dayMs); i < left.length; i++) {
|
||||
const departure = left[i];
|
||||
if (departure.sortMs > until) break;
|
||||
if (seen) {
|
||||
if (seen.has(departure)) continue;
|
||||
seen.add(departure);
|
||||
}
|
||||
if (departure.row.reservation_id === arrival.row.reservation_id) continue;
|
||||
if (returnsToOrigin(departure, arrival)) continue;
|
||||
if (!isConnection(arrival, departure)) continue;
|
||||
transfers.add(arrival.row.id);
|
||||
transfers.add(departure.row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transfers;
|
||||
}
|
||||
@@ -184,6 +184,39 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
|
||||
expect(jp.status).toBe(200);
|
||||
expect(jp.body.status).toBe('planned');
|
||||
});
|
||||
// #1535: the same journey booked as two flights, which is what an AirTrail import
|
||||
// whose legs never chained leaves behind. The hub is a legitimate 'to'/'from' pair, so
|
||||
// only the ground time between them says it was a plane change. Own user again.
|
||||
it('200 stats leaves out a layover country whose two legs are separate bookings', async () => {
|
||||
const flyerId = createUser(db as never, { username: 'atlas-flyer', email: 'atlas-flyer@test.example' }).user.id;
|
||||
const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
|
||||
const trip = createTrip(db as never, flyerId, {
|
||||
title: 'New York via Helsinki',
|
||||
start_date: iso(-20),
|
||||
end_date: iso(-10),
|
||||
});
|
||||
const day = iso(-15);
|
||||
const insertFlight = db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, ?, 'flight')");
|
||||
const insertEndpoint = db.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
const inbound = insertFlight.run(trip.id, 'BRU-HEL').lastInsertRowid;
|
||||
insertEndpoint.run(inbound, 'from', 0, 'Brussels', 50.9014, 4.4844, 'BRU', day, '07:00');
|
||||
insertEndpoint.run(inbound, 'to', 1, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '09:30');
|
||||
const onward = insertFlight.run(trip.id, 'HEL-JFK').lastInsertRowid;
|
||||
insertEndpoint.run(onward, 'from', 0, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '11:00');
|
||||
insertEndpoint.run(onward, 'to', 1, 'New York', 40.6413, -73.7781, 'JFK', day, '15:00');
|
||||
|
||||
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(flyerId));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const codes = (res.body.countries as { code: string }[]).map((c) => c.code);
|
||||
expect(codes).toContain('BE');
|
||||
expect(codes).toContain('US');
|
||||
expect(codes).not.toContain('FI');
|
||||
expect(res.body.stats.totalCountries).toBe(2);
|
||||
});
|
||||
|
||||
// ── /locate (#1115) ────────────────────────────────────────────────────────
|
||||
describe('GET locate', () => {
|
||||
it('resolves a coordinate to the country and the region the map can highlight', async () => {
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* The layover pairing behind #1535. Pure, so it gets its own file and no DB: the
|
||||
* whole question is which two reservation_endpoints rows describe one plane change
|
||||
* spread over two bookings, and every case below is a shape the AirTrail import
|
||||
* really produces (timed legs, date-only legs, a leg whose booking clock belongs
|
||||
* to the other airport).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
MAX_CLOCKLESS_DAY_GAP,
|
||||
MAX_LAYOVER_MS,
|
||||
transferEndpointIds,
|
||||
type FlightEndpointRow,
|
||||
} from '../../../src/nest/atlas/transfer-endpoints';
|
||||
|
||||
interface Airport {
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
const BRU: Airport = { code: 'BRU', lat: 50.9014, lng: 4.4844 };
|
||||
const HEL: Airport = { code: 'HEL', lat: 60.3172, lng: 24.9633 };
|
||||
const JFK: Airport = { code: 'JFK', lat: 40.6413, lng: -73.7781 };
|
||||
|
||||
/** Brussels' other airport, 45 km from BRU: the one a cheap return lands at. */
|
||||
const CRL: Airport = { code: 'CRL', lat: 50.4592, lng: 4.4538 };
|
||||
/** Amsterdam, 170 km from Brussels, which is two cities and not one. */
|
||||
const AMS: Airport = { code: 'AMS', lat: 52.3105, lng: 4.7683 };
|
||||
const FRA: Airport = { code: 'FRA', lat: 50.0379, lng: 8.5622 };
|
||||
|
||||
/** Same airport as HEL, but geocoded instead of imported, so no IATA code. */
|
||||
const HEL_NO_CODE: Airport = { code: null, lat: 60.3174, lng: 24.9629 };
|
||||
|
||||
interface Clock {
|
||||
date?: string | null;
|
||||
time?: string | null;
|
||||
/** The booking's own reservation_time/_end_time, which is all a date-only leg has. */
|
||||
fallback?: string | null;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
function endpoint(
|
||||
reservationId: number,
|
||||
role: 'from' | 'to' | 'stop',
|
||||
at: Airport,
|
||||
clock: Clock = {},
|
||||
tripId = 1,
|
||||
): FlightEndpointRow {
|
||||
return {
|
||||
id: ++nextId,
|
||||
reservation_id: reservationId,
|
||||
trip_id: tripId,
|
||||
role,
|
||||
code: at.code,
|
||||
lat: at.lat,
|
||||
lng: at.lng,
|
||||
local_date: clock.date ?? null,
|
||||
local_time: clock.time ?? null,
|
||||
fallback_time: clock.fallback ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* BRU → HEL as one booking and HEL → JFK as the next, which is what the importer
|
||||
* writes when the two legs never chained. `arrival`/`departure` are the clocks at
|
||||
* the hub, the only ones the pairing looks at.
|
||||
*/
|
||||
function splitChain(arrival: Clock, departure: Clock, hub: Airport = HEL, hubAgain: Airport = hub) {
|
||||
const rows = [
|
||||
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
|
||||
endpoint(1, 'to', hub, arrival),
|
||||
endpoint(2, 'from', hubAgain, departure),
|
||||
endpoint(2, 'to', JFK, { date: departure.date ?? '2026-08-01', time: '15:00' }),
|
||||
];
|
||||
return { rows, hubIds: [rows[1].id, rows[2].id].sort((a, b) => a - b) };
|
||||
}
|
||||
|
||||
const ids = (found: Set<number>) => [...found].sort((a, b) => a - b);
|
||||
|
||||
describe('transferEndpointIds', () => {
|
||||
it('keeps the 24 h window the importers use to merge the legs of one journey', () => {
|
||||
expect(MAX_LAYOVER_MS).toBe(24 * 3600 * 1000);
|
||||
expect(MAX_CLOCKLESS_DAY_GAP).toBe(1);
|
||||
});
|
||||
|
||||
it('pairs an arrival and an onward departure at the same airport code', () => {
|
||||
const { rows, hubIds } = splitChain({ date: '2026-08-01', time: '09:30' }, { date: '2026-08-01', time: '11:00' });
|
||||
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
|
||||
});
|
||||
|
||||
it('pairs on the coordinate when neither row carries a code', () => {
|
||||
const { rows, hubIds } = splitChain(
|
||||
{ date: '2026-08-01', time: '09:30' },
|
||||
{ date: '2026-08-01', time: '11:00' },
|
||||
HEL_NO_CODE,
|
||||
);
|
||||
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
|
||||
});
|
||||
|
||||
it('pairs a coded row with an uncoded one at the same coordinate', () => {
|
||||
const { rows, hubIds } = splitChain(
|
||||
{ date: '2026-08-01', time: '09:30' },
|
||||
{ date: '2026-08-01', time: '11:00' },
|
||||
HEL,
|
||||
HEL_NO_CODE,
|
||||
);
|
||||
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
|
||||
});
|
||||
|
||||
it('matches codes case-insensitively', () => {
|
||||
const { rows, hubIds } = splitChain(
|
||||
{ date: '2026-08-01', time: '09:30' },
|
||||
{ date: '2026-08-01', time: '11:00' },
|
||||
{ code: 'hel', lat: 1, lng: 1 },
|
||||
{ code: 'HEL', lat: 2, lng: 2 },
|
||||
);
|
||||
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
|
||||
});
|
||||
|
||||
it('pairs a turnaround with no ground time at all and one of exactly 24 h', () => {
|
||||
for (const departure of [
|
||||
{ date: '2026-08-01', time: '09:30' },
|
||||
{ date: '2026-08-02', time: '09:30' },
|
||||
]) {
|
||||
const { rows, hubIds } = splitChain({ date: '2026-08-01', time: '09:30' }, departure);
|
||||
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves a stay of 24 h and a minute, or a departure before the arrival, counted', () => {
|
||||
for (const departure of [
|
||||
{ date: '2026-08-02', time: '09:31' },
|
||||
{ date: '2026-08-01', time: '09:29' },
|
||||
]) {
|
||||
const { rows } = splitChain({ date: '2026-08-01', time: '09:30' }, departure);
|
||||
expect(transferEndpointIds(rows).size).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the calendar date when a leg has no clock, same day or the next', () => {
|
||||
for (const date of ['2026-08-01', '2026-08-02']) {
|
||||
const { rows, hubIds } = splitChain({ date: '2026-08-01' }, { date });
|
||||
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an unreadable clock as no clock and pairs on the date', () => {
|
||||
const { rows, hubIds } = splitChain({ date: '2026-08-01', time: 'midday' }, { date: '2026-08-01', time: '11:00' });
|
||||
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
|
||||
});
|
||||
|
||||
it('keeps a hub the traveler left two days later', () => {
|
||||
const { rows } = splitChain({ date: '2026-08-01' }, { date: '2026-08-03' });
|
||||
expect(transferEndpointIds(rows).size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a hub whose onward departure has no date at all', () => {
|
||||
const { rows } = splitChain({ date: '2026-08-01', time: '09:30' }, {});
|
||||
expect(transferEndpointIds(rows).size).toBe(0);
|
||||
});
|
||||
|
||||
it('reads the date out of the booking column but never its clock', () => {
|
||||
// A date-only AirTrail flight leaves the arrival endpoint without local parts, and
|
||||
// reservation_time holds the DEPARTURE airport's local clock, 22:00 in Brussels for
|
||||
// a flight that landed in Helsinki that morning. Using it as an arrival time would
|
||||
// make the onward 11:00 departure look like it left before the plane landed.
|
||||
const { rows, hubIds } = splitChain(
|
||||
{ fallback: '2026-08-01T22:00' },
|
||||
{ date: '2026-08-01', time: '11:00' },
|
||||
);
|
||||
expect(ids(transferEndpointIds(rows))).toEqual(hubIds);
|
||||
});
|
||||
|
||||
it('never pairs the two ends of one booking', () => {
|
||||
const rows = [
|
||||
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
|
||||
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
|
||||
];
|
||||
expect(transferEndpointIds(rows).size).toBe(0);
|
||||
});
|
||||
|
||||
it('never pairs across two trips', () => {
|
||||
const rows = [
|
||||
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
|
||||
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
|
||||
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }, 2),
|
||||
endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }, 2),
|
||||
];
|
||||
expect(transferEndpointIds(rows).size).toBe(0);
|
||||
});
|
||||
|
||||
it('never treats a same-day out-and-back as a connection', () => {
|
||||
// Flown out in the morning, back in the evening: the day was spent in Finland.
|
||||
const rows = [
|
||||
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
|
||||
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
|
||||
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '18:00' }),
|
||||
endpoint(2, 'to', BRU, { date: '2026-08-01', time: '20:30' }),
|
||||
];
|
||||
expect(transferEndpointIds(rows).size).toBe(0);
|
||||
});
|
||||
|
||||
it("never treats an out-and-back into the city's other airport as a connection", () => {
|
||||
// Flown to Helsinki in the morning, back to Brussels in the evening, but the return
|
||||
// lands at Charleroi instead of Zaventem. The day was still spent in Finland, and
|
||||
// the airport keys alone can never see that the two ends are one city.
|
||||
const rows = [
|
||||
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
|
||||
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
|
||||
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '18:00' }),
|
||||
endpoint(2, 'to', CRL, { date: '2026-08-01', time: '20:30' }),
|
||||
];
|
||||
expect(transferEndpointIds(rows).size).toBe(0);
|
||||
});
|
||||
|
||||
it('still pairs a hub between two airports that are their own cities', () => {
|
||||
// Amsterdam to Frankfurt to Brussels is a real connection, and AMS and BRU are
|
||||
// 170 km apart, so the radius that catches a sibling airport must not reach here.
|
||||
const rows = [
|
||||
endpoint(1, 'from', AMS, { date: '2026-08-01', time: '07:00' }),
|
||||
endpoint(1, 'to', FRA, { date: '2026-08-01', time: '08:15' }),
|
||||
endpoint(2, 'from', FRA, { date: '2026-08-01', time: '10:00' }),
|
||||
endpoint(2, 'to', BRU, { date: '2026-08-01', time: '11:05' }),
|
||||
];
|
||||
expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]);
|
||||
});
|
||||
|
||||
it('keeps an overnight return through the sibling airport out of the pairing', () => {
|
||||
// Same shape a night away on business has: 22.5 h on the ground, home into Gatwick
|
||||
// instead of Heathrow. Still inside the 24 h window, so only the direction says no.
|
||||
const LHR: Airport = { code: 'LHR', lat: 51.47, lng: -0.4543 };
|
||||
const LGW: Airport = { code: 'LGW', lat: 51.1537, lng: -0.1821 };
|
||||
const rows = [
|
||||
endpoint(1, 'from', LHR, { date: '2026-08-01', time: '06:30' }),
|
||||
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
|
||||
endpoint(2, 'from', HEL, { date: '2026-08-02', time: '08:00' }),
|
||||
endpoint(2, 'to', LGW, { date: '2026-08-02', time: '09:45' }),
|
||||
];
|
||||
expect(transferEndpointIds(rows).size).toBe(0);
|
||||
});
|
||||
|
||||
it('pairs only inside one trip, even where the hub and the clocks would fit', () => {
|
||||
// Two journeys through Helsinki on one day. The second trip's arrival has no onward
|
||||
// leg of its own, and the first trip's departure two hours later must not become one.
|
||||
const rows = [
|
||||
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
|
||||
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
|
||||
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }),
|
||||
endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }),
|
||||
endpoint(3, 'from', BRU, { date: '2026-08-01', time: '06:00' }, 2),
|
||||
endpoint(3, 'to', HEL, { date: '2026-08-01', time: '09:00' }, 2),
|
||||
];
|
||||
|
||||
expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]);
|
||||
});
|
||||
|
||||
it('pairs across midnight when only one of the two legs carries a clock', () => {
|
||||
// The scan looks ahead from the arrival's date, not from its instant, because a
|
||||
// date-only leg is anchored on midnight: landing at 23:50 and leaving on a flight
|
||||
// the next day is one connection, and so is a dateless landing left at 23:45.
|
||||
const lateArrival = splitChain({ date: '2026-08-01', time: '23:50' }, { date: '2026-08-02' });
|
||||
expect(ids(transferEndpointIds(lateArrival.rows))).toEqual(lateArrival.hubIds);
|
||||
|
||||
const lateDeparture = splitChain({ date: '2026-08-01' }, { date: '2026-08-02', time: '23:45' });
|
||||
expect(ids(transferEndpointIds(lateDeparture.rows))).toEqual(lateDeparture.hubIds);
|
||||
});
|
||||
|
||||
it('pairs nothing when neither side has a readable date', () => {
|
||||
for (const clock of [{}, { fallback: 'sometime in August' }, { date: '01/08/2026' }]) {
|
||||
const { rows } = splitChain(clock, clock);
|
||||
expect(transferEndpointIds(rows).size).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('pairs nothing when a date passes the shape check but is not a real date', () => {
|
||||
for (const time of [null, '09:30']) {
|
||||
const { rows } = splitChain({ date: '2026-13-45', time }, { date: '2026-13-45', time });
|
||||
expect(transferEndpointIds(rows).size).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('pairs an onward leg whose booking never got a second endpoint', () => {
|
||||
// A leg AirTrail has no coordinates for loses that endpoint, so the onward booking
|
||||
// has a 'from' and nothing else, which must not read as flying back to the origin.
|
||||
const rows = [
|
||||
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
|
||||
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
|
||||
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }),
|
||||
];
|
||||
expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]);
|
||||
});
|
||||
|
||||
it('ignores a stop row rather than reading it as an arrival', () => {
|
||||
// The callers filter roles in SQL, but a 'stop' is already handled by #1486 and must
|
||||
// never become one half of a pair here.
|
||||
const rows = [
|
||||
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
|
||||
endpoint(1, 'stop', HEL, { date: '2026-08-01', time: '09:30' }),
|
||||
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }),
|
||||
endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }),
|
||||
];
|
||||
expect(transferEndpointIds(rows).size).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a row that can be keyed neither by code nor by coordinate', () => {
|
||||
const { rows } = splitChain(
|
||||
{ date: '2026-08-01', time: '09:30' },
|
||||
{ date: '2026-08-01', time: '11:00' },
|
||||
{ code: null, lat: NaN, lng: NaN },
|
||||
);
|
||||
expect(transferEndpointIds(rows).size).toBe(0);
|
||||
});
|
||||
|
||||
it('has nothing to pair with a single endpoint', () => {
|
||||
expect(transferEndpointIds([endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' })]).size).toBe(0);
|
||||
});
|
||||
|
||||
it('marks only the pair that matches when the same hub is used again later', () => {
|
||||
// Out via Helsinki on the 1st, home via Helsinki on the 8th, but that time with
|
||||
// three days in the city, so only the first pass is a plane change.
|
||||
const rows = [
|
||||
endpoint(1, 'from', BRU, { date: '2026-08-01', time: '07:00' }),
|
||||
endpoint(1, 'to', HEL, { date: '2026-08-01', time: '09:30' }),
|
||||
endpoint(2, 'from', HEL, { date: '2026-08-01', time: '11:00' }),
|
||||
endpoint(2, 'to', JFK, { date: '2026-08-01', time: '15:00' }),
|
||||
endpoint(3, 'from', JFK, { date: '2026-08-08', time: '01:00' }),
|
||||
endpoint(3, 'to', HEL, { date: '2026-08-08', time: '10:00' }),
|
||||
endpoint(4, 'from', HEL, { date: '2026-08-11', time: '12:00' }),
|
||||
endpoint(4, 'to', BRU, { date: '2026-08-11', time: '14:30' }),
|
||||
];
|
||||
|
||||
expect(ids(transferEndpointIds(rows))).toEqual([rows[1].id, rows[2].id]);
|
||||
});
|
||||
});
|
||||
@@ -54,11 +54,14 @@ function insertReservationEndpoint(
|
||||
role: 'from' | 'to' | 'stop',
|
||||
sequence: number,
|
||||
lat: number,
|
||||
lng: number
|
||||
lng: number,
|
||||
code: string | null = null,
|
||||
localDate: string | null = null,
|
||||
localTime: string | null = null
|
||||
) {
|
||||
db.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng);
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng, code, localDate, localTime);
|
||||
}
|
||||
|
||||
function insertPlace(db: any, tripId: number, name: string, address: string | null = null) {
|
||||
@@ -202,6 +205,158 @@ describe('getStats', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── #1535: the layover the role filter can't see ─────────────────────────────
|
||||
|
||||
/**
|
||||
* When the legs of one journey arrive as two separate bookings (an AirTrail import
|
||||
* whose flights never chained), the connection airport is the legitimate 'to' of the
|
||||
* first and the legitimate 'from' of the second, so the role filter from #1486 lets
|
||||
* it through. Brussels to Helsinki to New York throughout: BE and US were reached,
|
||||
* Finland was a plane change.
|
||||
*/
|
||||
describe('getStats layover pairs across two bookings', () => {
|
||||
const BRU = { lat: 50.9014, lng: 4.4844, code: 'BRU' };
|
||||
const HEL = { lat: 60.3172, lng: 24.9633, code: 'HEL' };
|
||||
const JFK = { lat: 40.6413, lng: -73.7781, code: 'JFK' };
|
||||
|
||||
type Clock = { date: string | null; time: string | null };
|
||||
|
||||
function inboundToHelsinki(tripId: number, arrival: Clock) {
|
||||
const res = createReservation(testDb, tripId, { type: 'flight', title: 'BRU-HEL' });
|
||||
insertReservationEndpoint(testDb, res.id, 'from', 0, BRU.lat, BRU.lng, BRU.code, arrival.date, '07:00');
|
||||
insertReservationEndpoint(testDb, res.id, 'to', 1, HEL.lat, HEL.lng, HEL.code, arrival.date, arrival.time);
|
||||
return res;
|
||||
}
|
||||
|
||||
function onwardFromHelsinki(tripId: number, departure: Clock, type = 'flight') {
|
||||
const res = createReservation(testDb, tripId, { type, title: 'HEL-JFK' });
|
||||
insertReservationEndpoint(testDb, res.id, 'from', 0, HEL.lat, HEL.lng, HEL.code, departure.date, departure.time);
|
||||
insertReservationEndpoint(testDb, res.id, 'to', 1, JFK.lat, JFK.lng, JFK.code, departure.date, '15:00');
|
||||
return res;
|
||||
}
|
||||
|
||||
const codesFor = async (userId: number) =>
|
||||
(await atlas.stats(userId)).countries.map((c: { code: string }) => c.code);
|
||||
|
||||
it('ATLAS-UNIT-044 (#1535): a hub arrived at and left again the same day is not visited', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki' });
|
||||
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
|
||||
onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' });
|
||||
|
||||
const codes = await codesFor(user.id);
|
||||
expect(codes).toContain('BE');
|
||||
expect(codes).toContain('US');
|
||||
expect(codes).not.toContain('FI');
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-045 (#1535): a hub the traveler left two days later stays visited', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover' });
|
||||
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
|
||||
onwardFromHelsinki(trip.id, { date: '2026-08-03', time: '11:00' });
|
||||
|
||||
expect(await codesFor(user.id)).toContain('FI');
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-046 (#1535): a pair with no clocks pairs on the booking dates', async () => {
|
||||
// The case the reporter actually has: a date-only AirTrail flight leaves the
|
||||
// endpoints without local parts, so only reservation_time carries the day.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki' });
|
||||
const inbound = inboundToHelsinki(trip.id, { date: null, time: null });
|
||||
const onward = onwardFromHelsinki(trip.id, { date: null, time: null });
|
||||
const setTime = testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?');
|
||||
setTime.run('2026-08-01', inbound.id);
|
||||
setTime.run('2026-08-01', onward.id);
|
||||
|
||||
const codes = await codesFor(user.id);
|
||||
expect(codes).toContain('BE');
|
||||
expect(codes).toContain('US');
|
||||
expect(codes).not.toContain('FI');
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-047 (#1535): clockless bookings two days apart keep the hub visited', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover' });
|
||||
const inbound = inboundToHelsinki(trip.id, { date: null, time: null });
|
||||
const onward = onwardFromHelsinki(trip.id, { date: null, time: null });
|
||||
const setTime = testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?');
|
||||
setTime.run('2026-08-01', inbound.id);
|
||||
setTime.run('2026-08-03', onward.id);
|
||||
|
||||
expect(await codesFor(user.id)).toContain('FI');
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-048 (#1535): a place in the layover country keeps it visited', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki' });
|
||||
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
|
||||
onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' });
|
||||
insertPlace(testDb, trip.id, 'Kamppi Chapel', 'Simonkatu 7, Helsinki, Finland');
|
||||
|
||||
expect(await codesFor(user.id)).toContain('FI');
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-049 (#1366 guard): a car rental picked up where the flight landed keeps the country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Driving Finland' });
|
||||
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
|
||||
onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' }, 'car_rental');
|
||||
|
||||
expect(await codesFor(user.id)).toContain('FI');
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-050 (#1535): a cancelled onward flight is no connection', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Helsinki, stuck' });
|
||||
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
|
||||
const onward = onwardFromHelsinki(trip.id, { date: '2026-08-01', time: '11:00' });
|
||||
testDb.prepare('UPDATE reservations SET status = ? WHERE id = ?').run('cancelled', onward.id);
|
||||
|
||||
expect(await codesFor(user.id)).toContain('FI');
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-051 (#1535): an onward flight on another trip is another journey', async () => {
|
||||
// Landing home from one trip and leaving on the next within a day is not a plane
|
||||
// change, and the pairing spans every trip the user can see.
|
||||
const { user } = createUser(testDb);
|
||||
const arriving = createTrip(testDb, user.id, { title: 'Back from Brussels' });
|
||||
const leaving = createTrip(testDb, user.id, { title: 'Off to New York' });
|
||||
inboundToHelsinki(arriving.id, { date: '2026-08-01', time: '09:30' });
|
||||
onwardFromHelsinki(leaving.id, { date: '2026-08-01', time: '11:00' });
|
||||
|
||||
expect(await codesFor(user.id)).toContain('FI');
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-052 (#1535): a same-day out-and-back is a day in the country, not a transfer', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'A day in Helsinki' });
|
||||
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
|
||||
const back = createReservation(testDb, trip.id, { type: 'flight', title: 'HEL-BRU' });
|
||||
insertReservationEndpoint(testDb, back.id, 'from', 0, HEL.lat, HEL.lng, HEL.code, '2026-08-01', '18:00');
|
||||
insertReservationEndpoint(testDb, back.id, 'to', 1, BRU.lat, BRU.lng, BRU.code, '2026-08-01', '20:30');
|
||||
|
||||
expect(await codesFor(user.id)).toContain('FI');
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-053 (#1535): a return into the other airport of the same city is still a day away', async () => {
|
||||
// Same day trip as ATLAS-UNIT-052, but home into Charleroi instead of Zaventem, the
|
||||
// way a cheap return is booked. Comparing the airports alone reads that as flying
|
||||
// onwards and drops Finland, though the traveler spent the day in Helsinki.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'A day in Helsinki' });
|
||||
inboundToHelsinki(trip.id, { date: '2026-08-01', time: '09:30' });
|
||||
const back = createReservation(testDb, trip.id, { type: 'flight', title: 'HEL-CRL' });
|
||||
insertReservationEndpoint(testDb, back.id, 'from', 0, HEL.lat, HEL.lng, HEL.code, '2026-08-01', '18:00');
|
||||
insertReservationEndpoint(testDb, back.id, 'to', 1, 50.4592, 4.4538, 'CRL', '2026-08-01', '20:30');
|
||||
|
||||
const codes = await codesFor(user.id);
|
||||
expect(codes).toContain('FI');
|
||||
expect(codes).toContain('BE');
|
||||
});
|
||||
});
|
||||
|
||||
// ── the shared geocode cache ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,10 +44,19 @@ afterAll(() => { testDb.close(); });
|
||||
// ── getTravelStats — dashboard passport card ────────────────────────────────
|
||||
|
||||
describe('getTravelStats', () => {
|
||||
function endpoint(reservationId: number, role: 'from' | 'to' | 'stop', sequence: number, lat: number, lng: number) {
|
||||
function endpoint(
|
||||
reservationId: number,
|
||||
role: 'from' | 'to' | 'stop',
|
||||
sequence: number,
|
||||
lat: number,
|
||||
lng: number,
|
||||
code: string | null = null,
|
||||
localDate: string | null = null,
|
||||
localTime: string | null = null
|
||||
) {
|
||||
testDb.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng);
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng, code, localDate, localTime);
|
||||
}
|
||||
|
||||
// Every trip below is dated in the past: since #1048 the passport card only counts
|
||||
@@ -149,6 +158,51 @@ describe('getTravelStats', () => {
|
||||
expect(stats.countries).not.toContain('JP');
|
||||
});
|
||||
|
||||
// ── #1535: the layover split over two bookings ─────────────────────────────
|
||||
// The passport card derives its countries from the same endpoints, so it needs the
|
||||
// same pairing: a hub stored as the 'to' of one booking and the 'from' of the next
|
||||
// slips past the #1486 role filter. Brussels → Helsinki → New York.
|
||||
|
||||
function splitChainThroughHelsinki(tripId: number, onwardDate: string, onwardTime: string) {
|
||||
const inbound = createReservation(testDb, tripId, { type: 'flight', title: 'BRU-HEL' });
|
||||
endpoint(inbound.id, 'from', 0, 50.9014, 4.4844, 'BRU', '2023-05-01', '07:00');
|
||||
endpoint(inbound.id, 'to', 1, 60.3172, 24.9633, 'HEL', '2023-05-01', '09:30');
|
||||
const onward = createReservation(testDb, tripId, { type: 'flight', title: 'HEL-JFK' });
|
||||
endpoint(onward.id, 'from', 0, 60.3172, 24.9633, 'HEL', onwardDate, onwardTime);
|
||||
endpoint(onward.id, 'to', 1, 40.6413, -73.7781, 'JFK', onwardDate, '15:00');
|
||||
}
|
||||
|
||||
it('AUTH-DB-099: #1535 a plane change booked as two flights does not stamp the hub country', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki', start_date: PAST_START, end_date: PAST_END });
|
||||
splitChainThroughHelsinki(trip.id, '2023-05-01', '11:00');
|
||||
|
||||
const stats = atlas.getTravelStats(user.id);
|
||||
expect(stats.countries).toContain('BE');
|
||||
expect(stats.countries).toContain('US');
|
||||
expect(stats.countries).not.toContain('FI');
|
||||
});
|
||||
|
||||
it('AUTH-DB-100: #1535 a stopover of two days still stamps the hub country', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover', start_date: PAST_START, end_date: PAST_END });
|
||||
splitChainThroughHelsinki(trip.id, '2023-05-03', '11:00');
|
||||
|
||||
expect(atlas.getTravelStats(user.id).countries).toContain('FI');
|
||||
});
|
||||
|
||||
it('AUTH-DB-101: #1490 removing a country still subtracts it around the layover pairing', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki', start_date: PAST_START, end_date: PAST_END });
|
||||
splitChainThroughHelsinki(trip.id, '2023-05-01', '11:00');
|
||||
|
||||
atlas.unmarkCountry(user.id, 'BE');
|
||||
|
||||
const stats = atlas.getTravelStats(user.id);
|
||||
expect(stats.countries).not.toContain('BE');
|
||||
expect(stats.countries).toContain('US');
|
||||
});
|
||||
|
||||
it('AUTH-DB-097: #1048 a manually marked country stays stamped regardless of trip dates', () => {
|
||||
// The manual list is the user's own word, not derived from a trip — the date
|
||||
// filter must not reach it.
|
||||
|
||||
Reference in New Issue
Block a user