diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index c98aa80a3..c0e900364 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -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 { + 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 { @@ -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; + status: VisitStatus; } const placeCountries = this.resolvePlaceCountries(places); const countrySet = new Map(); 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(); 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 = {}; - 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 = {}; + 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 }> { + 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> = {}; - for (const [, entry] of cachedMap) { + const regionMap: Record< + string, + Map + > = {}; + 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 = {}; + 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, + }); } } diff --git a/server/tests/e2e/atlas.e2e.test.ts b/server/tests/e2e/atlas.e2e.test.ts index b53589745..63eccdc37 100644 --- a/server/tests/e2e/atlas.e2e.test.ts +++ b/server/tests/e2e/atlas.e2e.test.ts @@ -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'); + }); }); diff --git a/server/tests/integration/atlas.test.ts b/server/tests/integration/atlas.test.ts index 6c3f85ed9..e28dd9af1 100644 --- a/server/tests/integration/atlas.test.ts +++ b/server/tests/integration/atlas.test.ts @@ -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); diff --git a/server/tests/unit/nest/atlas.service.test.ts b/server/tests/unit/nest/atlas.service.test.ts index 5a5e0a322..6b079f058 100644 --- a/server/tests/unit/nest/atlas.service.test.ts +++ b/server/tests/unit/nest/atlas.service.test.ts @@ -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'); + }); +});