mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user