diff --git a/client/src/pages/atlas/atlasModel.test.ts b/client/src/pages/atlas/atlasModel.test.ts index ed22ed169..ccdf92e23 100644 --- a/client/src/pages/atlas/atlasModel.test.ts +++ b/client/src/pages/atlas/atlasModel.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { A2_TO_A3, bucketTooltipHeight, @@ -6,6 +6,7 @@ import { bucketTooltipPlacement, bucketTooltipWidth, countryStatus, + visitMonth, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, @@ -71,6 +72,31 @@ describe('countryStatus', () => { }); }); +describe('visitMonth (#1535)', () => { + // West of Greenwich, where new Date('2024-06-01') is still the last evening of May. + beforeEach(() => { + vi.stubEnv('TZ', 'America/New_York'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('reads the year and month as a local date, so the 1st stays in its own month', () => { + const june = visitMonth('2024-06-01')!; + expect([june.getFullYear(), june.getMonth(), june.getDate()]).toEqual([2024, 5, 1]); + const january = visitMonth('2025-01-01')!; + expect([january.getFullYear(), january.getMonth()]).toEqual([2025, 0]); + }); + + it('gives nothing for a missing or malformed date', () => { + expect(visitMonth(null)).toBeNull(); + expect(visitMonth(undefined)).toBeNull(); + expect(visitMonth('')).toBeNull(); + expect(visitMonth('June 2024')).toBeNull(); + }); +}); + describe('isCountryVisible', () => { it('always shows visited countries', () => { expect(isCountryVisible({ status: 'visited' }, false)).toBe(true); @@ -121,7 +147,7 @@ describe('withCountryMarkedVisited', () => { const prev = base({ countries: [ { code: 'FR', tripCount: 2, placeCount: 5, status: 'visited' }, - { code: 'JP', tripCount: 1, placeCount: 0, status: 'planned' }, + { code: 'JP', tripCount: 1, placeCount: 0, firstVisit: '2099-05-01', lastVisit: '2099-05-08', status: 'planned' }, ], stats: { totalTrips: 3, totalPlaces: 10, totalCountries: 1, totalDays: 14, totalCountriesPlanned: 1 }, continents: { Europe: 1 }, @@ -131,7 +157,8 @@ describe('withCountryMarkedVisited', () => { const next = withCountryMarkedVisited(prev, 'JP'); expect(next.countries).toHaveLength(2); - expect(next.countries.find((c) => c.code === 'JP')?.status).toBe('visited'); + // The planned trip's dates are not the dates of a visit, as the server agrees (#1535). + expect(next.countries.find((c) => c.code === 'JP')).toMatchObject({ status: 'visited', firstVisit: null, lastVisit: null }); expect(next.stats.totalCountries).toBe(2); expect(next.stats.totalCountriesPlanned).toBe(0); expect(next.continents).toEqual({ Europe: 1, Asia: 1 }); diff --git a/client/src/pages/atlas/atlasModel.ts b/client/src/pages/atlas/atlasModel.ts index 95d55992a..65e5678f6 100644 --- a/client/src/pages/atlas/atlasModel.ts +++ b/client/src/pages/atlas/atlasModel.ts @@ -61,6 +61,15 @@ export function isCountryVisible(c: Pick, showPlanned: b return countryStatus(c) === 'visited' || showPlanned } +/** + * The month a 'YYYY-MM-DD' visit date falls in, as a local date. `new Date('2025-06-01')` + * reads the string as UTC midnight, which is still May anywhere west of Greenwich (#1535). + */ +export function visitMonth(date: string | null | undefined): Date | null { + const match = date ? /^(\d{4})-(\d{2})/.exec(date) : null + return match ? new Date(Number(match[1]), Number(match[2]) - 1, 1) : null +} + /** * Fold a manual "I have been here" mark into the loaded data without refetching — the * map redraws from `data`, so a reload would flash the whole globe. A country that was @@ -75,7 +84,8 @@ export function withCountryMarkedVisited(prev: AtlasData, code: string): AtlasDa return { ...prev, countries: existing - ? prev.countries.map(c => (c.code === code ? { ...c, status: 'visited' as const } : c)) + // A country that was not visited yet has no visited trip to take dates from (#1535). + ? prev.countries.map(c => (c.code === code ? { ...c, status: 'visited' as const, firstVisit: null, lastVisit: null } : c)) : [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null, status: 'visited' as const }], stats: { ...prev.stats, diff --git a/client/src/pages/atlas/useAtlas.test.tsx b/client/src/pages/atlas/useAtlas.test.tsx index 8c01a4f10..bbb141a01 100644 --- a/client/src/pages/atlas/useAtlas.test.tsx +++ b/client/src/pages/atlas/useAtlas.test.tsx @@ -312,6 +312,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); delete window.__addToast; }); @@ -754,6 +755,24 @@ describe('useAtlas', () => { expect(atlas.confirmAction?.name).not.toBe('GERMANY_EN_ONLY'); }); + it('FE-HOOK-ATLAS-062: the country tooltip names both dates and shows their months (#1535)', async () => { + // FR's dates fall on the 1st, which a UTC parse moves into the month before over here. + vi.stubEnv('TZ', 'America/New_York'); + await mountAtlas({ geo: geoCountries }); + await waitFor(() => expect(lf.geoJson.length).toBeGreaterThan(0)); + + const countryLayer = lf.geoJson[0] as MockGeoJson; + const fr = countryLayer.entries.find((e) => (e.feature.properties as Record).ISO_A2 === 'FR'); + const html = fr!.layer.bindTooltip.mock.calls[0][0] as string; + + expect(html).toContain('First trip'); + expect(html).toContain('Last trip'); + expect(html).not.toContain('atlas.lastVisitLabel'); + const month = (y: number, m: number) => new Date(y, m, 1).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + expect(html).toContain(month(2023, 0)); + expect(html).toContain(month(2024, 5)); + }); + it('FE-HOOK-ATLAS-026: plugin tint layers are drawn in their own pane and redrawn on theme change', async () => { await mountAtlas({ geo: geoCountries, diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts index bece9fdda..4f5dca239 100644 --- a/client/src/pages/atlas/useAtlas.ts +++ b/client/src/pages/atlas/useAtlas.ts @@ -9,7 +9,7 @@ import { isVectorStyle } from '../../utils/tileUrl' import apiClient, { mapsApi, pluginsApi, type PluginAtlasLayer } from '../../api/client' import L from 'leaflet' import type { GeoJsonFeatureCollection } from '../../types' -import { A2_TO_A3, countryStatus, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, normalizeRegionName, regionCacheEvictions, withCountryMarkedVisited, wishlistA3Codes, countryColor, REGION_CACHE_MAX, bucketTooltipWidth, bucketTooltipPlacement, bucketTooltipNeedsScroll, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel' +import { A2_TO_A3, countryStatus, visitMonth, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, normalizeRegionName, regionCacheEvictions, withCountryMarkedVisited, wishlistA3Codes, countryColor, REGION_CACHE_MAX, bucketTooltipWidth, bucketTooltipPlacement, bucketTooltipNeedsScroll, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel' import { continentForCountry, escapeHtml, type VisitStatus } from '@trek/shared' import { useGlassGlare } from '../../components/Atlas/useGlassGlare' import { dawarichApi } from '../../api/dawarich' @@ -560,7 +560,7 @@ export function useAtlas() { if (c) { country_layer_by_a2_ref.current[c.code] = layer const name = resolveName(c.code) - const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) } + const formatDate = (d) => { const month = visitMonth(d); return month ? month.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) : '—' } // "First trip / Last trip" is simply wrong for a country you haven't reached yet — // a planned one gets a single departure date instead. const planned = countryStatus(c) !== 'visited' diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index 94fe8c18d..736aea4a0 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -16,6 +16,7 @@ import { KNOWN_COUNTRIES } from './known-countries'; import { cityFromAddress } from './city-from-address'; import { transferEndpointIds } from './transfer-endpoints'; import type { FlightEndpointRow } from './transfer-endpoints'; +import { countryVisitDates } from './visit-dates'; import { haversineKm } from '../common/geo'; /** @@ -262,21 +263,16 @@ export class AtlasService { } } - const countries = [...countrySet.values()].map((c) => { - const countryTrips = trips.filter((t) => c.tripIds.has(t.id)); - const dates = countryTrips - .map((t) => t.start_date) - .filter(Boolean) - .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); - return { - code: c.code, - placeCount: c.places.length, - tripCount: c.tripIds.size, - firstVisit: dates[0] || null, - lastVisit: dates[dates.length - 1] || null, - status: c.status, - }; - }); + // The dates are filled in at the end, once a manual mark or a booking has had its + // say on the status: they come only from the trips that match that final status. + const countries = [...countrySet.values()].map((c) => ({ + code: c.code, + placeCount: c.places.length, + tripCount: c.tripIds.size, + firstVisit: null as string | null, + lastVisit: null as string | null, + status: c.status, + })); const citySet = new Set(); for (const place of places) { @@ -367,14 +363,17 @@ export class AtlasService { // Collapse to one entry per coordinate before resolving countries — // getCountryFromCoords is a point-in-polygon scan and used to run once per point. - const endpointStatus = new Map(); + 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 }); + if (seen) { + seen.status = strongerVisitStatus(seen.status, status); + seen.tripIds.add(e.trip_id); + } else endpointStatus.set(key, { lat: e.lat, lng: e.lng, status, tripIds: new Set([e.trip_id]) }); } + const bookingTripIds = new Map>(); for (const e of endpointStatus.values()) { const code = getCountryFromCoords(e.lat, e.lng); if (!code || hidden.has(code)) continue; @@ -382,6 +381,20 @@ export class AtlasService { if (existing) existing.status = strongerVisitStatus(existing.status, e.status); else countries.push({ code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null, status: e.status }); + const ids = bookingTripIds.get(code) ?? new Set(); + for (const id of e.tripIds) ids.add(id); + bookingTripIds.set(code, ids); + } + + // A booking can be what makes a country with places visited, so its trip dates the + // country as well. A country reached by bookings alone has no trip in its tooltip + // and stays without dates. + for (const c of countries) { + const fromPlaces = countrySet.get(c.code); + if (!fromPlaces) continue; + const fromBookings = bookingTripIds.get(c.code); + const dated = trips.filter((t) => fromPlaces.tripIds.has(t.id) || fromBookings?.has(t.id)); + Object.assign(c, countryVisitDates(dated, c.status, now)); } // Everything below counts actual visits only. countries[] still carries planned and diff --git a/server/src/nest/atlas/visit-dates.ts b/server/src/nest/atlas/visit-dates.ts new file mode 100644 index 000000000..6f6a567b5 --- /dev/null +++ b/server/src/nest/atlas/visit-dates.ts @@ -0,0 +1,40 @@ +/** + * The "First trip / Last trip" dates Atlas shows for a country (#1535). + * + * Both used to be the earliest and the latest start_date of every trip with a + * place in the country. A single trip therefore showed its departure month twice, + * the month you came home never appeared, and a trip you had only booked could + * become the last visit of a country you had already been to. + * + * Now only the trips that give the country its status count: the visited ones for + * a visited country, the planned ones for a planned country. firstVisit is where + * the earliest of them starts and lastVisit where the latest ends. A trip you are + * still on ends today, not on the day you plan to fly back. + * + * Dates stay 'YYYY-MM-DD' strings and are compared as such, like tripVisitStatus + * does, so no timezone can shift them on the way. + */ +import { tripVisitStatus, type VisitStatus } from '@trek/shared'; + +export interface DatedTrip { + start_date?: string | null; + end_date?: string | null; +} + +export function countryVisitDates( + trips: DatedTrip[], + status: VisitStatus, + today: string, +): { firstVisit: string | null; lastVisit: string | null } { + let firstVisit: string | null = null; + let lastVisit: string | null = null; + for (const trip of trips) { + const start = trip.start_date || trip.end_date; + if (!start || tripVisitStatus(trip.start_date, trip.end_date, today) !== status) continue; + let end = trip.end_date && trip.end_date > start ? trip.end_date : start; + if (status === 'visited' && end > today) end = today; + if (!firstVisit || start < firstVisit) firstVisit = start; + if (!lastVisit || end > lastVisit) lastVisit = end; + } + return { firstVisit, lastVisit }; +} diff --git a/server/tests/e2e/atlas.e2e.test.ts b/server/tests/e2e/atlas.e2e.test.ts index 2c1ba6ae0..e327db6ab 100644 --- a/server/tests/e2e/atlas.e2e.test.ts +++ b/server/tests/e2e/atlas.e2e.test.ts @@ -171,8 +171,9 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { 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' }); + // #1535: the dates run from the start to the end of the trip, not start to start. + expect(byCode['IT']).toMatchObject({ status: 'visited', firstVisit: iso(-40), lastVisit: iso(-30) }); + expect(byCode['JP']).toMatchObject({ status: 'planned', firstVisit: iso(30), lastVisit: iso(40) }); expect(res.body.stats.totalCountries).toBe(1); expect(res.body.stats.totalCountriesPlanned).toBe(1); expect(res.body.countries.length).toBeGreaterThan(res.body.stats.totalCountries); diff --git a/server/tests/unit/nest/atlas-visit-dates.test.ts b/server/tests/unit/nest/atlas-visit-dates.test.ts new file mode 100644 index 000000000..fa52500da --- /dev/null +++ b/server/tests/unit/nest/atlas-visit-dates.test.ts @@ -0,0 +1,81 @@ +/** + * The First trip / Last trip dates behind the Atlas tooltip (#1535). Pure, so no DB: + * every case is a set of trips and the status the country ended up with. + */ +import { describe, it, expect } from 'vitest'; + +import { countryVisitDates } from '../../../src/nest/atlas/visit-dates'; + +const TODAY = '2026-09-15'; + +describe('countryVisitDates', () => { + it('ATLAS-DATES-001: a trip over two months keeps both months', () => { + expect(countryVisitDates([{ start_date: '2025-03-28', end_date: '2025-04-12' }], 'visited', TODAY)).toEqual({ + firstVisit: '2025-03-28', + lastVisit: '2025-04-12', + }); + }); + + it('ATLAS-DATES-002: several visited trips span from the earliest start to the latest end', () => { + const trips = [ + { start_date: '2024-06-01', end_date: '2024-06-10' }, + { start_date: '2022-01-05', end_date: '2022-01-20' }, + { start_date: '2023-07-01', end_date: '2023-07-03' }, + ]; + expect(countryVisitDates(trips, 'visited', TODAY)).toEqual({ firstVisit: '2022-01-05', lastVisit: '2024-06-10' }); + }); + + it('ATLAS-DATES-003: a visited country ignores its planned and dateless trips', () => { + const trips = [ + { start_date: '2025-05-01', end_date: '2025-05-04' }, + { start_date: '2027-01-01', end_date: '2027-01-09' }, + { start_date: null, end_date: null }, + ]; + expect(countryVisitDates(trips, 'visited', TODAY)).toEqual({ firstVisit: '2025-05-01', lastVisit: '2025-05-04' }); + }); + + it('ATLAS-DATES-004: a trip still under way ends today', () => { + expect(countryVisitDates([{ start_date: '2026-09-10', end_date: '2026-09-20' }], 'visited', TODAY)).toEqual({ + firstVisit: '2026-09-10', + lastVisit: TODAY, + }); + }); + + it('ATLAS-DATES-005: a planned country keeps its future dates', () => { + const trips = [ + { start_date: '2027-03-01', end_date: '2027-03-08' }, + { start_date: '2026-12-20', end_date: '2026-12-27' }, + ]; + expect(countryVisitDates(trips, 'planned', TODAY)).toEqual({ firstVisit: '2026-12-20', lastVisit: '2027-03-08' }); + }); + + it('ATLAS-DATES-006: a trip with one date only starts and ends on it', () => { + expect(countryVisitDates([{ start_date: '2025-02-02' }], 'visited', TODAY)).toEqual({ + firstVisit: '2025-02-02', + lastVisit: '2025-02-02', + }); + expect(countryVisitDates([{ start_date: null, end_date: '2025-02-09' }], 'visited', TODAY)).toEqual({ + firstVisit: '2025-02-09', + lastVisit: '2025-02-09', + }); + }); + + it('ATLAS-DATES-007: an end typed before the start does not end the trip before it began', () => { + expect(countryVisitDates([{ start_date: '2025-02-10', end_date: '2025-02-01' }], 'visited', TODAY)).toEqual({ + firstVisit: '2025-02-10', + lastVisit: '2025-02-10', + }); + }); + + it('ATLAS-DATES-008: no matching trip gives no dates', () => { + expect(countryVisitDates([], 'visited', TODAY)).toEqual({ firstVisit: null, lastVisit: null }); + expect(countryVisitDates([{ start_date: '2027-01-01', end_date: '2027-01-02' }], 'visited', TODAY)).toEqual({ + firstVisit: null, + lastVisit: null, + }); + expect(countryVisitDates([{ start_date: null, end_date: null }], 'idea', TODAY)).toEqual({ + firstVisit: null, + lastVisit: null, + }); + }); +}); diff --git a/server/tests/unit/nest/atlas.service.test.ts b/server/tests/unit/nest/atlas.service.test.ts index 01f774c7c..6ccc44b79 100644 --- a/server/tests/unit/nest/atlas.service.test.ts +++ b/server/tests/unit/nest/atlas.service.test.ts @@ -1653,6 +1653,110 @@ describe('getStats — visited vs planned vs idea (#1048)', () => { }); }); +describe('getStats first and last visit (#1535)', () => { + it('ATLAS-UNIT-054: one trip runs from its start to its end, not from its start to its start', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris', start_date: PAST_START, end_date: PAST_END }); + insertPlace(testDb, trip.id, 'Louvre', '75001 Paris, France'); + + const stats = await atlas.stats(user.id); + + expect(stats.countries.find((c) => c.code === 'FR')).toMatchObject({ firstVisit: PAST_START, lastVisit: PAST_END }); + }); + + it('ATLAS-UNIT-055: a trip only booked does not become the last visit of a country already visited', 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); + + expect(stats.countries.find((c) => c.code === 'DE')).toMatchObject({ + status: 'visited', + firstVisit: PAST_START, + lastVisit: PAST_END, + }); + }); + + it('ATLAS-UNIT-056: a trip still under way ends today', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Rome now', start_date: isoOffsetDays(-2), end_date: isoOffsetDays(5) }); + insertPlace(testDb, trip.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy'); + + const stats = await atlas.stats(user.id); + + expect(stats.countries.find((c) => c.code === 'IT')).toMatchObject({ + firstVisit: isoOffsetDays(-2), + lastVisit: isoOffsetDays(0), + }); + }); + + it('ATLAS-UNIT-057: a planned country keeps the dates of its planned trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Tokyo soon', start_date: FUTURE_START, end_date: FUTURE_END }); + insertPlace(testDb, trip.id, 'Senso-ji', 'Asakusa, Tokyo, Japan'); + + const stats = await atlas.stats(user.id); + + expect(stats.countries.find((c) => c.code === 'JP')).toMatchObject({ + status: 'planned', + firstVisit: FUTURE_START, + lastVisit: FUTURE_END, + }); + }); + + it('ATLAS-UNIT-058: a country marked by hand takes no dates from a trip that has not happened yet', 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'); + testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP'); + + const stats = await atlas.stats(user.id); + + expect(stats.countries.find((c) => c.code === 'JP')).toMatchObject({ + status: 'visited', + firstVisit: null, + lastVisit: null, + }); + }); + + it('ATLAS-UNIT-060: a flight that makes a country with places visited dates it by its own trip', async () => { + const { user } = createUser(testDb); + const flown = createTrip(testDb, user.id, { title: 'Tokyo 2023', start_date: PAST_START, end_date: PAST_END }); + const reservation = createReservation(testDb, flown.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 planned = createTrip(testDb, user.id, { title: 'Japan again', start_date: FUTURE_START, end_date: FUTURE_END }); + insertPlace(testDb, planned.id, 'Senso-ji', 'Asakusa, Tokyo, Japan'); + + const stats = await atlas.stats(user.id); + + expect(stats.countries.find((c) => c.code === 'JP')).toMatchObject({ + status: 'visited', + tripCount: 1, + firstVisit: PAST_START, + lastVisit: PAST_END, + }); + }); + + it('ATLAS-UNIT-059: a country reached only by a flight and a dateless idea stay without dates', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Tokyo', start_date: PAST_START, end_date: PAST_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 someday = createTrip(testDb, user.id, { title: 'Someday: Paris' }); + insertPlace(testDb, someday.id, 'Louvre', '75001 Paris, France'); + + const stats = await atlas.stats(user.id); + + expect(stats.countries.find((c) => c.code === 'JP')).toMatchObject({ tripCount: 0, firstVisit: null, lastVisit: null }); + expect(stats.countries.find((c) => c.code === 'FR')).toMatchObject({ status: 'idea', firstVisit: null, lastVisit: null }); + }); +}); + describe('getCountryPlaces — status (#1048)', () => { it('ATLAS-UNIT-040: the country sheet reports the same status the map paints', async () => { const { user } = createUser(testDb); diff --git a/shared/src/i18n/ar/atlas.ts b/shared/src/i18n/ar/atlas.ts index 7673b2d52..4c6e5138f 100644 --- a/shared/src/i18n/ar/atlas.ts +++ b/shared/src/i18n/ar/atlas.ts @@ -52,6 +52,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'أوقيانوسيا', 'atlas.other': 'أخرى', 'atlas.firstVisit': 'أول رحلة', + 'atlas.lastVisitLabel': 'آخر رحلة', 'atlas.tripSingular': 'رحلة', 'atlas.tripPlural': 'رحلات', 'atlas.placeVisited': 'مكان تمت زيارته', diff --git a/shared/src/i18n/br/atlas.ts b/shared/src/i18n/br/atlas.ts index 0efe9ddb4..37db4763f 100644 --- a/shared/src/i18n/br/atlas.ts +++ b/shared/src/i18n/br/atlas.ts @@ -52,6 +52,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Oceania', 'atlas.other': 'Outro', 'atlas.firstVisit': 'Primeira viagem', + 'atlas.lastVisitLabel': 'Última viagem', 'atlas.tripSingular': 'Viagem', 'atlas.tripPlural': 'Viagens', 'atlas.placeVisited': 'Lugar visitado', diff --git a/shared/src/i18n/ca/atlas.ts b/shared/src/i18n/ca/atlas.ts index 76cfb73ca..215868573 100644 --- a/shared/src/i18n/ca/atlas.ts +++ b/shared/src/i18n/ca/atlas.ts @@ -28,6 +28,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Oceania', 'atlas.other': 'Altres', 'atlas.firstVisit': 'Primer viatge', + 'atlas.lastVisitLabel': 'Darrer viatge', 'atlas.tripSingular': 'Viatge', 'atlas.tripPlural': 'Viatges', 'atlas.placeVisited': 'Lloc visitat', diff --git a/shared/src/i18n/cs/atlas.ts b/shared/src/i18n/cs/atlas.ts index 78bdd823d..f20a901af 100644 --- a/shared/src/i18n/cs/atlas.ts +++ b/shared/src/i18n/cs/atlas.ts @@ -50,6 +50,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Oceánie', 'atlas.other': 'Ostatní', 'atlas.firstVisit': 'První cesta', + 'atlas.lastVisitLabel': 'Poslední cesta', 'atlas.tripSingular': 'Cesta', 'atlas.tripPlural': 'Cesty', 'atlas.placeVisited': 'Navštívené místo', diff --git a/shared/src/i18n/de/atlas.ts b/shared/src/i18n/de/atlas.ts index 7f383348e..48a91c684 100644 --- a/shared/src/i18n/de/atlas.ts +++ b/shared/src/i18n/de/atlas.ts @@ -52,6 +52,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Ozeanien', 'atlas.other': 'Andere', 'atlas.firstVisit': 'Erste Reise', + 'atlas.lastVisitLabel': 'Letzte Reise', 'atlas.tripSingular': 'Reise', 'atlas.tripPlural': 'Reisen', 'atlas.placeVisited': 'Ort besucht', diff --git a/shared/src/i18n/en/atlas.ts b/shared/src/i18n/en/atlas.ts index 831c50740..9bfab8101 100644 --- a/shared/src/i18n/en/atlas.ts +++ b/shared/src/i18n/en/atlas.ts @@ -52,6 +52,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Oceania', 'atlas.other': 'Other', 'atlas.firstVisit': 'First trip', + 'atlas.lastVisitLabel': 'Last trip', 'atlas.tripSingular': 'Trip', 'atlas.tripPlural': 'Trips', 'atlas.placeVisited': 'Place visited', diff --git a/shared/src/i18n/es/atlas.ts b/shared/src/i18n/es/atlas.ts index 1f1f7a6cf..97ccee865 100644 --- a/shared/src/i18n/es/atlas.ts +++ b/shared/src/i18n/es/atlas.ts @@ -28,6 +28,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Oceanía', 'atlas.other': 'Otros', 'atlas.firstVisit': 'Primer viaje', + 'atlas.lastVisitLabel': 'Último viaje', 'atlas.tripSingular': 'Viaje', 'atlas.tripPlural': 'Viajes', 'atlas.placeVisited': 'Lugar visitado', diff --git a/shared/src/i18n/fr/atlas.ts b/shared/src/i18n/fr/atlas.ts index ddeb51dd4..06548bbf1 100644 --- a/shared/src/i18n/fr/atlas.ts +++ b/shared/src/i18n/fr/atlas.ts @@ -28,6 +28,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Océanie', 'atlas.other': 'Autre', 'atlas.firstVisit': 'Premier voyage', + 'atlas.lastVisitLabel': 'Dernier voyage', 'atlas.tripSingular': 'Voyage', 'atlas.tripPlural': 'Voyages', 'atlas.placeVisited': 'Lieu visité', diff --git a/shared/src/i18n/gr/atlas.ts b/shared/src/i18n/gr/atlas.ts index 567274479..2e6847315 100644 --- a/shared/src/i18n/gr/atlas.ts +++ b/shared/src/i18n/gr/atlas.ts @@ -52,6 +52,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Ωκεανία', 'atlas.other': 'Άλλο', 'atlas.firstVisit': 'Πρώτο ταξίδι', + 'atlas.lastVisitLabel': 'Τελευταίο ταξίδι', 'atlas.tripSingular': 'Ταξίδι', 'atlas.tripPlural': 'Ταξίδια', 'atlas.placeVisited': 'Μέρος που επισκεφθήκατε', diff --git a/shared/src/i18n/hu/atlas.ts b/shared/src/i18n/hu/atlas.ts index 4dfe0fa46..6f8a9ed75 100644 --- a/shared/src/i18n/hu/atlas.ts +++ b/shared/src/i18n/hu/atlas.ts @@ -52,6 +52,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Óceánia', 'atlas.other': 'Egyéb', 'atlas.firstVisit': 'Első utazás', + 'atlas.lastVisitLabel': 'Utolsó utazás', 'atlas.tripSingular': 'Utazás', 'atlas.tripPlural': 'Utazások', 'atlas.placeVisited': 'Meglátogatott hely', diff --git a/shared/src/i18n/id/atlas.ts b/shared/src/i18n/id/atlas.ts index e2e54c343..b85dfb14f 100644 --- a/shared/src/i18n/id/atlas.ts +++ b/shared/src/i18n/id/atlas.ts @@ -52,6 +52,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Oseania', 'atlas.other': 'Lainnya', 'atlas.firstVisit': 'Perjalanan pertama', + 'atlas.lastVisitLabel': 'Perjalanan terakhir', 'atlas.tripSingular': 'Perjalanan', 'atlas.tripPlural': 'Perjalanan', 'atlas.placeVisited': 'Tempat dikunjungi', diff --git a/shared/src/i18n/it/atlas.ts b/shared/src/i18n/it/atlas.ts index 4a17662d4..e2a265fca 100644 --- a/shared/src/i18n/it/atlas.ts +++ b/shared/src/i18n/it/atlas.ts @@ -50,6 +50,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Oceania', 'atlas.other': 'Altro', 'atlas.firstVisit': 'Primo viaggio', + 'atlas.lastVisitLabel': 'Ultimo viaggio', 'atlas.tripSingular': 'Viaggio', 'atlas.tripPlural': 'Viaggi', 'atlas.placeVisited': 'Luogo visitato', diff --git a/shared/src/i18n/ja/atlas.ts b/shared/src/i18n/ja/atlas.ts index 8ce65eefb..7fcdd554a 100644 --- a/shared/src/i18n/ja/atlas.ts +++ b/shared/src/i18n/ja/atlas.ts @@ -52,6 +52,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'オセアニア', 'atlas.other': 'その他', 'atlas.firstVisit': '最初の旅行', + 'atlas.lastVisitLabel': '最後の旅行', 'atlas.tripSingular': '旅行', 'atlas.tripPlural': '旅行', 'atlas.placeVisited': '訪問した場所', diff --git a/shared/src/i18n/ko/atlas.ts b/shared/src/i18n/ko/atlas.ts index 61cde2b63..5b922cd90 100644 --- a/shared/src/i18n/ko/atlas.ts +++ b/shared/src/i18n/ko/atlas.ts @@ -52,6 +52,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': '오세아니아', 'atlas.other': '기타', 'atlas.firstVisit': '첫 번째 여행', + 'atlas.lastVisitLabel': '마지막 여행', 'atlas.tripSingular': '여행', 'atlas.tripPlural': '여행', 'atlas.placeVisited': '방문한 장소', diff --git a/shared/src/i18n/nl/atlas.ts b/shared/src/i18n/nl/atlas.ts index c0930c70d..e10a1d419 100644 --- a/shared/src/i18n/nl/atlas.ts +++ b/shared/src/i18n/nl/atlas.ts @@ -28,6 +28,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Oceanië', 'atlas.other': 'Overig', 'atlas.firstVisit': 'Eerste reis', + 'atlas.lastVisitLabel': 'Laatste reis', 'atlas.tripSingular': 'Reis', 'atlas.tripPlural': 'Reizen', 'atlas.placeVisited': 'Bezochte plaats', diff --git a/shared/src/i18n/pl/atlas.ts b/shared/src/i18n/pl/atlas.ts index fdb9c3f7b..b13187083 100644 --- a/shared/src/i18n/pl/atlas.ts +++ b/shared/src/i18n/pl/atlas.ts @@ -50,6 +50,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Oceania', 'atlas.other': 'Inne', 'atlas.firstVisit': 'Pierwsza podróż', + 'atlas.lastVisitLabel': 'Ostatnia podróż', 'atlas.tripSingular': 'Podróż', 'atlas.tripPlural': 'Podróże', 'atlas.placeVisited': 'Odwiedzone miejsce', diff --git a/shared/src/i18n/ru/atlas.ts b/shared/src/i18n/ru/atlas.ts index 80b66a776..35c43b52a 100644 --- a/shared/src/i18n/ru/atlas.ts +++ b/shared/src/i18n/ru/atlas.ts @@ -28,6 +28,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Океания', 'atlas.other': 'Другое', 'atlas.firstVisit': 'Первая поездка', + 'atlas.lastVisitLabel': 'Последняя поездка', 'atlas.tripSingular': 'Поездка', 'atlas.tripPlural': 'Поездки', 'atlas.placeVisited': 'Посещённое место', diff --git a/shared/src/i18n/sv/atlas.ts b/shared/src/i18n/sv/atlas.ts index 7ed94f53d..57a8bf221 100644 --- a/shared/src/i18n/sv/atlas.ts +++ b/shared/src/i18n/sv/atlas.ts @@ -52,6 +52,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Oceanien', 'atlas.other': 'Andra', 'atlas.firstVisit': 'Första resan', + 'atlas.lastVisitLabel': 'Den senaste resan', 'atlas.tripSingular': 'Resa', 'atlas.tripPlural': 'Resor', 'atlas.placeVisited': 'Plats besökt', diff --git a/shared/src/i18n/tr/atlas.ts b/shared/src/i18n/tr/atlas.ts index ac65d9030..11bab2d13 100644 --- a/shared/src/i18n/tr/atlas.ts +++ b/shared/src/i18n/tr/atlas.ts @@ -52,6 +52,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Okyanusya', 'atlas.other': 'Diğer', 'atlas.firstVisit': 'İlk yolculuk', + 'atlas.lastVisitLabel': 'Son yolculuk', 'atlas.tripSingular': 'Seyahat', 'atlas.tripPlural': 'Geziler', 'atlas.placeVisited': 'Ziyaret edilen yer', diff --git a/shared/src/i18n/uk/atlas.ts b/shared/src/i18n/uk/atlas.ts index cbc01f2a8..5611b0400 100644 --- a/shared/src/i18n/uk/atlas.ts +++ b/shared/src/i18n/uk/atlas.ts @@ -28,6 +28,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Океанія', 'atlas.other': 'Інше', 'atlas.firstVisit': 'Перша поїздка', + 'atlas.lastVisitLabel': 'Остання поїздка', 'atlas.tripSingular': 'Поїздка', 'atlas.tripPlural': 'Поїздки', 'atlas.placeVisited': 'Відвідане місце', diff --git a/shared/src/i18n/vi/atlas.ts b/shared/src/i18n/vi/atlas.ts index 97d3d8460..eeff5e427 100644 --- a/shared/src/i18n/vi/atlas.ts +++ b/shared/src/i18n/vi/atlas.ts @@ -52,6 +52,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': 'Châu Đại Dương', 'atlas.other': 'Khác', 'atlas.firstVisit': 'Chuyến đi đầu tiên', + 'atlas.lastVisitLabel': 'Chuyến đi cuối cùng', 'atlas.tripSingular': 'Chuyến đi', 'atlas.tripPlural': 'Chuyến đi', 'atlas.placeVisited': 'Nơi đã ghé thăm', diff --git a/shared/src/i18n/zh-TW/atlas.ts b/shared/src/i18n/zh-TW/atlas.ts index 06608fd6a..9259a2304 100644 --- a/shared/src/i18n/zh-TW/atlas.ts +++ b/shared/src/i18n/zh-TW/atlas.ts @@ -28,6 +28,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': '大洋洲', 'atlas.other': '其他', 'atlas.firstVisit': '首次旅行', + 'atlas.lastVisitLabel': '最近旅行', 'atlas.tripSingular': '次旅行', 'atlas.tripPlural': '次旅行', 'atlas.placeVisited': '個地點已訪問', diff --git a/shared/src/i18n/zh/atlas.ts b/shared/src/i18n/zh/atlas.ts index 72cc86256..f52f559dd 100644 --- a/shared/src/i18n/zh/atlas.ts +++ b/shared/src/i18n/zh/atlas.ts @@ -28,6 +28,7 @@ const atlas: TranslationStrings = { 'atlas.oceania': '大洋洲', 'atlas.other': '其他', 'atlas.firstVisit': '首次旅行', + 'atlas.lastVisitLabel': '最近旅行', 'atlas.tripSingular': '次旅行', 'atlas.tripPlural': '次旅行', 'atlas.placeVisited': '个地点已访问',