feat(server): count only started trips as visited in the atlas

Every country now carries the status of the strongest source it came from:
a country reached by both a past and a future trip is visited, one that only
appears in upcoming trips is planned. Marking a country by hand still wins
over everything, since that is a statement of fact rather than a date.

totalCountries drops to the visited ones and totalCountriesPlanned sits
beside it; continents and mostVisited follow the same rule. countries[]
stays a superset of what it returned before, so an older client keeps
painting the map exactly as it did.

The endpoint query has to select trip_id to know which trip a leg belongs
to, which makes its DISTINCT per trip — the coordinates are collapsed again
in JS so the point-in-polygon lookup still runs once per location.
This commit is contained in:
Maurice
2026-08-03 19:39:27 +02:00
committed by Maurice
parent ddba45ab3e
commit 269bd9140f
4 changed files with 429 additions and 29 deletions
+115 -24
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { CONTINENT_MAP } from '@trek/shared';
import { CONTINENT_MAP, strongerVisitStatus, todayUtc, tripVisitStatus, VisitStatus } from '@trek/shared';
import { Trip, Place } from '../../types';
import { DatabaseService } from '../database/database.service';
import {
@@ -73,6 +73,15 @@ export class AtlasService {
return this.db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds) as Place[];
}
/**
* Trip id → whether that trip counts as visited, planned or a dateless idea (#1048).
* Everything country-shaped downstream takes its status from here, so the map, the
* counters and the country detail sheet can never disagree about the same trip.
*/
private tripStatusMap(trips: Trip[], today: string): Map<number, VisitStatus> {
return new Map(trips.map((t) => [t.id, tripVisitStatus(t.start_date, t.end_date, today)]));
}
// ── Country resolution (batch DB cache + sync fallback + background geocoding) ──
private resolvePlaceCountries(places: Place[]): Map<number, string> {
@@ -157,19 +166,26 @@ export class AtlasService {
}
const places = this.getPlacesForTrips(tripIds);
const now = todayUtc();
const tripStatus = this.tripStatusMap(trips, now);
interface CountryEntry {
code: string;
places: { id: number; name: string; lat: number | null; lng: number | null }[];
tripIds: Set<number>;
status: VisitStatus;
}
const placeCountries = this.resolvePlaceCountries(places);
const countrySet = new Map<string, CountryEntry>();
for (const place of places) {
const code = placeCountries.get(place.id);
if (code) {
if (!countrySet.has(code)) {
countrySet.set(code, { code, places: [], tripIds: new Set() });
const status = tripStatus.get(place.trip_id) ?? 'idea';
const entry = countrySet.get(code);
if (entry) {
entry.status = strongerVisitStatus(entry.status, status);
} else {
countrySet.set(code, { code, places: [], tripIds: new Set(), status });
}
countrySet
.get(code)!
@@ -200,6 +216,7 @@ export class AtlasService {
tripCount: c.tripIds.size,
firstVisit: dates[0] || null,
lastVisit: dates[dates.length - 1] || null,
status: c.status,
};
});
@@ -241,8 +258,20 @@ export class AtlasService {
}[];
for (const mc of manualCountries) {
if (hidden.has(mc.country_code)) continue;
if (!countries.find((c) => c.code === mc.country_code)) {
countries.push({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null });
const existing = countries.find((c) => c.code === mc.country_code);
if (existing) {
// Marking a country by hand is a statement of fact and outranks the dates of a
// trip that happens to go there later (#1048).
existing.status = 'visited';
} else {
countries.push({
code: mc.country_code,
placeCount: 0,
tripCount: 0,
firstVisit: null,
lastVisit: null,
status: 'visited',
});
}
}
@@ -254,29 +283,53 @@ export class AtlasService {
const endpoints = this.db
.prepare(
`
SELECT DISTINCT e.lat, e.lng
SELECT DISTINCT r.trip_id, e.lat, e.lng
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 { lat: number; lng: number }[];
.all(...tripIds) as { trip_id: number; lat: number; lng: number }[];
// 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 —
// 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) {
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 });
}
for (const e of endpointStatus.values()) {
const code = getCountryFromCoords(e.lat, e.lng);
if (code && !hidden.has(code) && !countries.find((c) => c.code === code)) {
countries.push({ code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null });
}
if (!code || hidden.has(code)) continue;
const existing = countries.find((c) => c.code === code);
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 mostVisited = countries.length > 0 ? countries.reduce((a, b) => (a.placeCount > b.placeCount ? a : b)) : null;
// Everything below counts actual visits only. countries[] still carries planned and
// dateless entries so the client can draw them once the user asks for them (#1048).
const visited = countries.filter((c) => c.status === 'visited');
const planned = countries.filter((c) => c.status === 'planned');
const mostVisited = visited.length > 0 ? visited.reduce((a, b) => (a.placeCount > b.placeCount ? a : b)) : null;
const continents: Record<string, number> = {};
countries.forEach((c) => {
visited.forEach((c) => {
const cont = CONTINENT_MAP[c.code] || 'Other';
continents[cont] = (continents[cont] || 0) + 1;
});
const now = new Date().toISOString().split('T')[0];
const continentsPlanned: Record<string, number> = {};
planned.forEach((c) => {
const cont = CONTINENT_MAP[c.code] || 'Other';
continentsPlanned[cont] = (continentsPlanned[cont] || 0) + 1;
});
const pastTrips = trips
.filter((t) => t.end_date && t.end_date <= now)
.sort((a, b) => b.end_date!.localeCompare(a.end_date!));
@@ -330,12 +383,15 @@ export class AtlasService {
stats: {
totalTrips: trips.length,
totalPlaces: places.length,
totalCountries: countries.length,
totalCountries: visited.length,
totalCountriesPlanned: planned.length,
totalCountriesIdea: countries.length - visited.length - planned.length,
totalDays,
totalCities,
},
mostVisited,
continents,
continentsPlanned,
lastTrip,
nextTrip,
streak,
@@ -355,7 +411,7 @@ export class AtlasService {
const marked = !!this.db
.prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?')
.get(userId, code);
return { places: [], trips: [], manually_marked: marked };
return { places: [], trips: [], manually_marked: marked, status: marked ? 'visited' : 'idea' };
}
const places = this.getPlacesForTrips(tripIds);
@@ -392,7 +448,15 @@ export class AtlasService {
const isManuallyMarked = !!this.db
.prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?')
.get(userId, code);
return { places: matchingPlaces, trips: matchingTrips, manually_marked: isManuallyMarked };
// Take the status from the same trip classification stats() uses rather than deriving
// it again here — the detail sheet and the map must agree on what this country is.
const tripStatus = this.tripStatusMap(trips, todayUtc());
let status: VisitStatus = 'idea';
for (const id of matchingTripIds) status = strongerVisitStatus(status, tripStatus.get(id) ?? 'idea');
if (isManuallyMarked) status = 'visited';
return { places: matchingPlaces, trips: matchingTrips, manually_marked: isManuallyMarked, status };
}
// ── Mark / unmark country ─────────────────────────────────────────────────
@@ -524,13 +588,21 @@ export class AtlasService {
// ── Visited regions ───────────────────────────────────────────────────────
async visitedRegions(
userId: number,
): Promise<{ regions: Record<string, { code: string; name: string; placeCount: number }[]> }> {
async visitedRegions(userId: number): Promise<{
regions: Record<
string,
{ code: string; name: string; placeCount: number; status: VisitStatus; manuallyMarked?: boolean }[]
>;
}> {
const trips = this.getUserTrips(userId);
const tripIds = trips.map((t) => t.id);
const places = this.getPlacesForTrips(tripIds);
// Regions carry the same status as their country, otherwise zooming into a merely
// planned country would reveal regions painted as visited (#1048).
const tripStatus = this.tripStatusMap(trips, todayUtc());
const placeTrip = new Map(places.map((p) => [p.id, p.trip_id]));
// Check DB cache first
const placeIds = places.filter((p) => p.lat && p.lng).map((p) => p.id);
const cached =
@@ -567,22 +639,32 @@ export class AtlasService {
}
// Group by country → regions with place counts
const regionMap: Record<string, Map<string, { code: string; name: string; placeCount: number }>> = {};
for (const [, entry] of cachedMap) {
const regionMap: Record<
string,
Map<string, { code: string; name: string; placeCount: number; status: VisitStatus }>
> = {};
for (const [placeId, entry] of cachedMap) {
const tripId = placeTrip.get(placeId);
const status = (tripId !== undefined ? tripStatus.get(tripId) : undefined) ?? 'idea';
if (!regionMap[entry.country_code]) regionMap[entry.country_code] = new Map();
const existing = regionMap[entry.country_code].get(entry.region_code);
if (existing) {
existing.placeCount++;
existing.status = strongerVisitStatus(existing.status, status);
} else {
regionMap[entry.country_code].set(entry.region_code, {
code: entry.region_code,
name: entry.region_name,
placeCount: 1,
status,
});
}
}
const result: Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]> = {};
const result: Record<
string,
{ code: string; name: string; placeCount: number; status: VisitStatus; manuallyMarked?: boolean }[]
> = {};
for (const [country, regions] of Object.entries(regionMap)) {
result[country] = [...regions.values()];
}
@@ -591,8 +673,17 @@ export class AtlasService {
const manualRegions = this.listManuallyVisitedRegions(userId);
for (const r of manualRegions) {
if (!result[r.country_code]) result[r.country_code] = [];
if (!result[r.country_code].find((x) => x.code === r.region_code)) {
result[r.country_code].push({ code: r.region_code, name: r.region_name, placeCount: 0, manuallyMarked: true });
const existing = result[r.country_code].find((x) => x.code === r.region_code);
if (existing) {
existing.status = 'visited';
} else {
result[r.country_code].push({
code: r.region_code,
name: r.region_name,
placeCount: 0,
status: 'visited',
manuallyMarked: true,
});
}
}
+33 -1
View File
@@ -36,7 +36,7 @@ vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { createUser } from '../helpers/factories';
import { createUser, createTrip } from '../helpers/factories';
import { AtlasModule } from '../../src/nest/atlas/atlas.module';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
@@ -152,4 +152,36 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Item not found' });
});
// #1048 — the whole point of the feature over the real wire: a booked-but-not-taken
// trip must still reach the client (so the map can draw it) without counting as a
// visit. Runs on its own user so the trip-less pin above stays trip-less.
it('200 stats keeps a future trip out of the visited count but still ships it as planned', async () => {
const plannerId = createUser(db as never, { username: 'atlas-planner', email: 'atlas-planner@test.example' }).user.id;
// Offsets from today, not literal dates — a hardcoded future date expires.
const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
const past = createTrip(db as never, plannerId, { title: 'Rome, last month', start_date: iso(-40), end_date: iso(-30) });
const future = createTrip(db as never, plannerId, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
// Address-only (no lat/lng), keeping the file's no-background-geocode property.
const insertPlace = db.prepare('INSERT INTO places (trip_id, name, address) VALUES (?, ?, ?)');
insertPlace.run(past.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy');
insertPlace.run(future.id, 'Senso-ji', 'Asakusa, Tokyo, Japan');
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(plannerId));
expect(res.status).toBe(200);
const byCode = Object.fromEntries((res.body.countries as { code: string }[]).map((c) => [c.code, c]));
expect(byCode['IT']).toMatchObject({ status: 'visited' });
expect(byCode['JP']).toMatchObject({ status: 'planned' });
expect(res.body.stats.totalCountries).toBe(1);
expect(res.body.stats.totalCountriesPlanned).toBe(1);
expect(res.body.countries.length).toBeGreaterThan(res.body.stats.totalCountries);
expect(res.body.continents).toEqual({ Europe: 1 });
expect(res.body.continentsPlanned).toEqual({ Asia: 1 });
// The country sheet agrees with the map.
const jp = await request(server).get('/api/addons/atlas/country/jp').set('Cookie', sessionCookie(plannerId));
expect(jp.status).toBe(200);
expect(jp.body.status).toBe('planned');
});
});
+25 -1
View File
@@ -47,7 +47,7 @@ import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { createUser, createTrip } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
let nestApp: INestApplication;
@@ -104,6 +104,30 @@ describe('Atlas stats', () => {
expect(res.body).toHaveProperty('stats');
});
it('ATLAS-014 — a future trip is returned as planned and excluded from totalCountries (#1048)', async () => {
const { user } = createUser(testDb);
// Offsets from today rather than literal dates, so this cannot expire.
const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
const past = createTrip(testDb, user.id, { title: 'Rome, last month', start_date: iso(-40), end_date: iso(-30) });
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
const insertPlace = testDb.prepare('INSERT INTO places (trip_id, name, address) VALUES (?, ?, ?)');
insertPlace.run(past.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy');
insertPlace.run(future.id, 'Senso-ji', 'Asakusa, Tokyo, Japan');
const res = await request(app)
.get('/api/addons/atlas/stats')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const countries = res.body.countries as { code: string; status: string }[];
expect(countries.find(c => c.code === 'IT')?.status).toBe('visited');
expect(countries.find(c => c.code === 'JP')?.status).toBe('planned');
expect(res.body.stats.totalCountries).toBe(1);
expect(res.body.stats.totalCountriesPlanned).toBe(1);
// The whole point: the map gets more countries than the passport counter shows.
expect(countries.length).toBeGreaterThan(res.body.stats.totalCountries);
});
it('ATLAS-002 — GET /api/atlas/country/:code returns places in country', async () => {
const { user } = createUser(testDb);
+256 -3
View File
@@ -106,7 +106,9 @@ describe('getStats', () => {
it('ATLAS-UNIT-002: returns the country with the highest placeCount as mostVisited', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Euro Tour' });
// Dated in the past on purpose: since #1048 only a trip that has already started
// counts as visited, and mostVisited/totalCountries only look at visited countries.
const trip = createTrip(testDb, user.id, { title: 'Euro Tour', start_date: '2023-05-01', end_date: '2023-05-10' });
// 3 places in France, 1 in Germany → France should win
for (let i = 0; i < 3; i++) {
@@ -138,7 +140,8 @@ describe('getStats', () => {
it('ATLAS-UNIT-004: single country yields mostVisited equal to that country', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Italy Trip' });
// Past dates — see ATLAS-UNIT-002; a dateless trip is an 'idea' and never mostVisited.
const trip = createTrip(testDb, user.id, { title: 'Italy Trip', start_date: '2023-05-01', end_date: '2023-05-10' });
insertPlace(testDb, trip.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy');
const stats = await atlas.stats(user.id);
@@ -1075,7 +1078,8 @@ describe('atlas quirk fixes', () => {
const result = atlas.countryPlaces(user.id, 'JP');
expect(result).toEqual({ places: [], trips: [], manually_marked: true });
// status rides along since #1048 — a manual mark is a visit even with no trips.
expect(result).toEqual({ places: [], trips: [], manually_marked: true, status: 'visited' });
expect(atlas.countryPlaces(user.id, 'FR').manually_marked).toBe(false);
});
@@ -1146,3 +1150,252 @@ describe('atlas quirk fixes', () => {
expect(row).toBeUndefined();
});
});
// ── #1048: visited vs planned vs idea ────────────────────────────────────────
//
// Before this, every trip painted its countries as visited — a flight booked for
// next summer coloured the map as if the traveller had already been there. The
// trip's dates now decide, and countries[] carries that verdict as `status`.
// Offsets from "now", never literal dates: a hardcoded 2026 date stops being the
// future at some point and would silently flip these assertions to green-for-the-
// wrong-reason (or red) months after the fact.
function isoOffsetDays(days: number): string {
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
}
const PAST_START = isoOffsetDays(-40);
const PAST_END = isoOffsetDays(-30);
const FUTURE_START = isoOffsetDays(30);
const FUTURE_END = isoOffsetDays(40);
describe('getStats — visited vs planned vs idea (#1048)', () => {
it('ATLAS-UNIT-029: a country from a trip that already happened is visited', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rome, last month', start_date: PAST_START, end_date: PAST_END });
insertPlace(testDb, trip.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy');
const stats = await atlas.stats(user.id);
expect(stats.countries).toEqual([expect.objectContaining({ code: 'IT', status: 'visited' })]);
expect(stats.stats.totalCountries).toBe(1);
expect(stats.stats.totalCountriesPlanned).toBe(0);
expect(stats.stats.totalCountriesIdea).toBe(0);
});
it('ATLAS-UNIT-030: a country from a future trip is planned — listed, but not counted as visited', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Japan, next month', start_date: FUTURE_START, end_date: FUTURE_END });
insertPlace(testDb, trip.id, 'Senso-ji', 'Asakusa, Tokyo, Japan');
const stats = await atlas.stats(user.id);
// Still in countries[] — the client needs it to draw the dashed outline — but it
// must not inflate the "countries visited" counter.
expect(stats.countries.find((c: any) => c.code === 'JP')).toMatchObject({ status: 'planned' });
expect(stats.stats.totalCountries).toBe(0);
expect(stats.stats.totalCountriesPlanned).toBe(1);
expect(stats.countries.length).toBeGreaterThan(stats.stats.totalCountries);
});
it('ATLAS-UNIT-031: a trip that has started but not ended counts as visited — you are there now', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'Currently in Berlin',
start_date: isoOffsetDays(-2),
end_date: isoOffsetDays(5),
});
insertPlace(testDb, trip.id, 'Brandenburger Tor', 'Pariser Platz, Berlin, Germany');
const stats = await atlas.stats(user.id);
expect(stats.countries.find((c: any) => c.code === 'DE')).toMatchObject({ status: 'visited' });
expect(stats.stats.totalCountries).toBe(1);
});
it('ATLAS-UNIT-032: a trip starting today is already visited (the <= boundary)', async () => {
const { user } = createUser(testDb);
const today = isoOffsetDays(0);
const trip = createTrip(testDb, user.id, { title: 'Flying out today', start_date: today, end_date: isoOffsetDays(6) });
insertPlace(testDb, trip.id, 'Louvre', '75001 Paris, France');
const stats = await atlas.stats(user.id);
expect(stats.countries.find((c: any) => c.code === 'FR')).toMatchObject({ status: 'visited' });
expect(stats.stats.totalCountriesPlanned).toBe(0);
});
it('ATLAS-UNIT-033: a trip with no dates at all is an idea, not a plan', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Someday: Japan' });
insertPlace(testDb, trip.id, 'Senso-ji', 'Asakusa, Tokyo, Japan');
const stats = await atlas.stats(user.id);
expect(stats.countries.find((c: any) => c.code === 'JP')).toMatchObject({ status: 'idea' });
expect(stats.stats.totalCountries).toBe(0);
expect(stats.stats.totalCountriesPlanned).toBe(0);
expect(stats.stats.totalCountriesIdea).toBe(1);
});
it('ATLAS-UNIT-034: a country with both a past and a future trip takes the stronger status', async () => {
const { user } = createUser(testDb);
const past = createTrip(testDb, user.id, { title: 'Munich 2023', start_date: PAST_START, end_date: PAST_END });
const future = createTrip(testDb, user.id, { title: 'Munich again', start_date: FUTURE_START, end_date: FUTURE_END });
insertPlace(testDb, past.id, 'Marienplatz', 'Marienplatz, Munich, Germany');
insertPlace(testDb, future.id, 'Englischer Garten', 'Englischer Garten, Munich, Germany');
const stats = await atlas.stats(user.id);
const de = stats.countries.find((c: any) => c.code === 'DE');
expect(de).toMatchObject({ status: 'visited', tripCount: 2, placeCount: 2 });
expect(stats.stats.totalCountries).toBe(1);
expect(stats.stats.totalCountriesPlanned).toBe(0);
});
it('ATLAS-UNIT-035: marking a country by hand outranks a future trip going there', async () => {
// "I have been to Japan" is a statement of fact; a booking for next month cannot
// downgrade it back to a plan.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Japan, next month', start_date: FUTURE_START, end_date: FUTURE_END });
insertPlace(testDb, trip.id, 'Senso-ji', 'Asakusa, Tokyo, Japan');
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
const stats = await atlas.stats(user.id);
const jp = stats.countries.filter((c: any) => c.code === 'JP');
expect(jp).toHaveLength(1); // upgraded in place, not appended a second time
expect(jp[0]).toMatchObject({ status: 'visited', placeCount: 1 });
expect(stats.stats.totalCountries).toBe(1);
expect(stats.stats.totalCountriesPlanned).toBe(0);
});
it('ATLAS-UNIT-036: removing a country hides it even while it is only planned (#1490 tombstone)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: FUTURE_START, end_date: FUTURE_END });
const reservation = createReservation(testDb, trip.id, { type: 'flight' });
insertReservationEndpoint(testDb, reservation.id, 'from', 0, 50.9014, 4.4844); // Brussels
insertReservationEndpoint(testDb, reservation.id, 'to', 1, 35.6762, 139.6503); // Tokyo
const before = await atlas.stats(user.id);
expect(before.countries.find((c: any) => c.code === 'JP')).toMatchObject({ status: 'planned' });
atlas.unmarkCountry(user.id, 'JP');
const after = await atlas.stats(user.id);
expect(after.countries.map((c: any) => c.code)).not.toContain('JP');
expect(after.countries.map((c: any) => c.code)).toContain('BE');
expect(after.stats.totalCountriesPlanned).toBe(1);
});
it('ATLAS-UNIT-037: continents counts visited only; the planned ones live in continentsPlanned', async () => {
const { user } = createUser(testDb);
const past = createTrip(testDb, user.id, { title: 'Paris 2023', start_date: PAST_START, end_date: PAST_END });
const future = createTrip(testDb, user.id, { title: 'Tokyo soon', start_date: FUTURE_START, end_date: FUTURE_END });
insertPlace(testDb, past.id, 'Louvre', '75001 Paris, France');
insertPlace(testDb, future.id, 'Senso-ji', 'Asakusa, Tokyo, Japan');
const stats = await atlas.stats(user.id);
expect(stats.continents).toEqual({ Europe: 1 });
expect(stats.continentsPlanned).toEqual({ Asia: 1 });
});
it('ATLAS-UNIT-038: mostVisited ignores planned countries even when they have more places', async () => {
const { user } = createUser(testDb);
const past = createTrip(testDb, user.id, { title: 'Rome 2023', start_date: PAST_START, end_date: PAST_END });
const future = createTrip(testDb, user.id, { title: 'Japan soon', start_date: FUTURE_START, end_date: FUTURE_END });
insertPlace(testDb, past.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy');
for (let i = 0; i < 3; i++) insertPlace(testDb, future.id, `Tokyo Place ${i}`, `Street ${i}, Tokyo, Japan`);
const stats = await atlas.stats(user.id);
// JP has 3 places to IT's 1, but you have not been there yet.
expect(stats.mostVisited).not.toBeNull();
expect(stats.mostVisited!.code).toBe('IT');
expect(stats.mostVisited!.placeCount).toBe(1);
});
it('ATLAS-UNIT-039: countries reached only by a future flight leg are planned, not visited (#1366 path)', async () => {
// Endpoint-derived countries go through their own dedupe-per-coordinate branch,
// so they need their own guard that the trip's dates still decide.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: FUTURE_START, end_date: FUTURE_END });
const reservation = createReservation(testDb, trip.id, { type: 'flight' });
insertReservationEndpoint(testDb, reservation.id, 'from', 0, 50.9014, 4.4844); // Brussels
insertReservationEndpoint(testDb, reservation.id, 'to', 1, 35.6762, 139.6503); // Tokyo
const stats = await atlas.stats(user.id);
expect(stats.countries.find((c: any) => c.code === 'BE')).toMatchObject({ status: 'planned' });
expect(stats.countries.find((c: any) => c.code === 'JP')).toMatchObject({ status: 'planned' });
expect(stats.stats.totalCountries).toBe(0);
expect(stats.stats.totalCountriesPlanned).toBe(2);
});
});
describe('getCountryPlaces — status (#1048)', () => {
it('ATLAS-UNIT-040: the country sheet reports the same status the map paints', async () => {
const { user } = createUser(testDb);
const past = createTrip(testDb, user.id, { title: 'Paris 2023', start_date: PAST_START, end_date: PAST_END });
const future = createTrip(testDb, user.id, { title: 'Tokyo soon', start_date: FUTURE_START, end_date: FUTURE_END });
insertPlace(testDb, past.id, 'Louvre', '75001 Paris, France');
insertPlace(testDb, future.id, 'Senso-ji', 'Asakusa, Tokyo, Japan');
expect(atlas.countryPlaces(user.id, 'FR').status).toBe('visited');
expect(atlas.countryPlaces(user.id, 'JP').status).toBe('planned');
// A country with no trip and no manual mark has nothing behind it at all.
expect(atlas.countryPlaces(user.id, 'BR').status).toBe('idea');
});
it('ATLAS-UNIT-041: a manual mark makes the sheet visited even for a future-only country', async () => {
const { user } = createUser(testDb);
const future = createTrip(testDb, user.id, { title: 'Tokyo soon', start_date: FUTURE_START, end_date: FUTURE_END });
insertPlace(testDb, future.id, 'Senso-ji', 'Asakusa, Tokyo, Japan');
atlas.markCountry(user.id, 'JP');
const result = atlas.countryPlaces(user.id, 'JP');
expect(result.manually_marked).toBe(true);
expect(result.status).toBe('visited');
expect(result.places).toHaveLength(1);
});
});
describe('getVisitedRegions — status (#1048)', () => {
it('ATLAS-UNIT-042: a region inherits its trip status; a manually marked one is visited', async () => {
const { user } = createUser(testDb);
const future = createTrip(testDb, user.id, { title: 'Paris soon', start_date: FUTURE_START, end_date: FUTURE_END });
const place = insertPlaceWithCoords(testDb, future.id, 'Paris Hotel', 48.85, 2.35);
// Pre-seed the region cache so nothing geocodes in the background (see ATLAS-UNIT-020).
testDb
.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(place.id, 'FR', 'FR-75', 'Île-de-France');
testDb
.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)')
.run(user.id, 'DE-BY', 'Bayern', 'DE');
const result = await atlas.visitedRegions(user.id);
// Zooming into a merely planned country must not reveal regions painted as visited.
expect(result.regions['FR']).toEqual([expect.objectContaining({ code: 'FR-75', status: 'planned' })]);
expect(result.regions['DE']).toEqual([expect.objectContaining({ code: 'DE-BY', status: 'visited' })]);
});
it('ATLAS-UNIT-043: marking a planned region by hand upgrades it to visited', async () => {
const { user } = createUser(testDb);
const future = createTrip(testDb, user.id, { title: 'Paris soon', start_date: FUTURE_START, end_date: FUTURE_END });
const place = insertPlaceWithCoords(testDb, future.id, 'Paris Hotel', 48.85, 2.35);
testDb
.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(place.id, 'FR', 'FR-75', 'Île-de-France');
expect((await atlas.visitedRegions(user.id)).regions['FR'][0].status).toBe('planned');
atlas.markRegion(user.id, 'FR-75', 'Île-de-France', 'FR');
const after = await atlas.visitedRegions(user.id);
// Still one entry — the manual mark upgrades the derived region rather than duplicating it.
expect(after.regions['FR']).toHaveLength(1);
expect(after.regions['FR'][0].status).toBe('visited');
});
});