fix(atlas): date a country's first and last trip from the trips that count

The Atlas tooltip took both dates from the start dates of every trip with a
place in the country. One trip showed the month it began twice, the month it
ended never appeared, and a trip only booked could become the last visit of a
country already visited.

Both now come from the trips that give the country its status, places and
bookings alike: the start of the earliest and the end of the latest, with a
trip still under way ending today. The optimistic mark on the client clears the
dates the same way the server does.

The second label rendered the raw key atlas.lastVisitLabel since #1908 removed
it from the locales; the strings are back as they were. The month is built
from the date's digits now, because a 'YYYY-MM-DD' string parses as UTC and
showed the month before anywhere west of Greenwich.

Closes #1535
This commit is contained in:
Maurice
2026-09-15 21:36:34 +02:00
parent 2efa843306
commit acd6f1acdf
32 changed files with 344 additions and 26 deletions
+31 -18
View File
@@ -16,6 +16,7 @@ import { KNOWN_COUNTRIES } from './known-countries';
import { cityFromAddress } from './city-from-address';
import { transferEndpointIds } from './transfer-endpoints';
import type { FlightEndpointRow } from './transfer-endpoints';
import { countryVisitDates } from './visit-dates';
import { haversineKm } from '../common/geo';
/**
@@ -262,21 +263,16 @@ export class AtlasService {
}
}
const countries = [...countrySet.values()].map((c) => {
const countryTrips = trips.filter((t) => c.tripIds.has(t.id));
const dates = countryTrips
.map((t) => t.start_date)
.filter(Boolean)
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
return {
code: c.code,
placeCount: c.places.length,
tripCount: c.tripIds.size,
firstVisit: dates[0] || null,
lastVisit: dates[dates.length - 1] || null,
status: c.status,
};
});
// The dates are filled in at the end, once a manual mark or a booking has had its
// say on the status: they come only from the trips that match that final status.
const countries = [...countrySet.values()].map((c) => ({
code: c.code,
placeCount: c.places.length,
tripCount: c.tripIds.size,
firstVisit: null as string | null,
lastVisit: null as string | null,
status: c.status,
}));
const citySet = new Set<string>();
for (const place of places) {
@@ -367,14 +363,17 @@ export class AtlasService {
// 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 }>();
const endpointStatus = new Map<string, { lat: number; lng: number; status: VisitStatus; tripIds: Set<number> }>();
for (const e of endpoints) {
const status = tripStatus.get(e.trip_id) ?? 'idea';
const key = `${e.lat},${e.lng}`;
const seen = endpointStatus.get(key);
if (seen) seen.status = strongerVisitStatus(seen.status, status);
else endpointStatus.set(key, { lat: e.lat, lng: e.lng, status });
if (seen) {
seen.status = strongerVisitStatus(seen.status, status);
seen.tripIds.add(e.trip_id);
} else endpointStatus.set(key, { lat: e.lat, lng: e.lng, status, tripIds: new Set([e.trip_id]) });
}
const bookingTripIds = new Map<string, Set<number>>();
for (const e of endpointStatus.values()) {
const code = getCountryFromCoords(e.lat, e.lng);
if (!code || hidden.has(code)) continue;
@@ -382,6 +381,20 @@ export class AtlasService {
if (existing) existing.status = strongerVisitStatus(existing.status, e.status);
else
countries.push({ code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null, status: e.status });
const ids = bookingTripIds.get(code) ?? new Set<number>();
for (const id of e.tripIds) ids.add(id);
bookingTripIds.set(code, ids);
}
// A booking can be what makes a country with places visited, so its trip dates the
// country as well. A country reached by bookings alone has no trip in its tooltip
// and stays without dates.
for (const c of countries) {
const fromPlaces = countrySet.get(c.code);
if (!fromPlaces) continue;
const fromBookings = bookingTripIds.get(c.code);
const dated = trips.filter((t) => fromPlaces.tripIds.has(t.id) || fromBookings?.has(t.id));
Object.assign(c, countryVisitDates(dated, c.status, now));
}
// Everything below counts actual visits only. countries[] still carries planned and
+40
View File
@@ -0,0 +1,40 @@
/**
* The "First trip / Last trip" dates Atlas shows for a country (#1535).
*
* Both used to be the earliest and the latest start_date of every trip with a
* place in the country. A single trip therefore showed its departure month twice,
* the month you came home never appeared, and a trip you had only booked could
* become the last visit of a country you had already been to.
*
* Now only the trips that give the country its status count: the visited ones for
* a visited country, the planned ones for a planned country. firstVisit is where
* the earliest of them starts and lastVisit where the latest ends. A trip you are
* still on ends today, not on the day you plan to fly back.
*
* Dates stay 'YYYY-MM-DD' strings and are compared as such, like tripVisitStatus
* does, so no timezone can shift them on the way.
*/
import { tripVisitStatus, type VisitStatus } from '@trek/shared';
export interface DatedTrip {
start_date?: string | null;
end_date?: string | null;
}
export function countryVisitDates(
trips: DatedTrip[],
status: VisitStatus,
today: string,
): { firstVisit: string | null; lastVisit: string | null } {
let firstVisit: string | null = null;
let lastVisit: string | null = null;
for (const trip of trips) {
const start = trip.start_date || trip.end_date;
if (!start || tripVisitStatus(trip.start_date, trip.end_date, today) !== status) continue;
let end = trip.end_date && trip.end_date > start ? trip.end_date : start;
if (status === 'visited' && end > today) end = today;
if (!firstVisit || start < firstVisit) firstVisit = start;
if (!lastVisit || end > lastVisit) lastVisit = end;
}
return { firstVisit, lastVisit };
}