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:
Maurice
2026-08-17 14:20:58 +02:00
committed by Maurice
parent 1a0834b866
commit 54693474f1
6 changed files with 942 additions and 15 deletions
+52 -9
View File
@@ -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());
}
+305
View File
@@ -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;
}
+33
View File
@@ -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]);
});
});
+158 -3
View File
@@ -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.