Merge pull request #2376 from liketrek/fix/atlas-visit-dates

fix(atlas): date a country's first and last trip from the trips that count
This commit is contained in:
Maurice
2026-09-15 22:05:34 +02:00
committed by GitHub
32 changed files with 344 additions and 26 deletions
+30 -3
View File
@@ -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 });
+11 -1
View File
@@ -61,6 +61,15 @@ export function isCountryVisible(c: Pick<AtlasCountry, 'status'>, 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,
+19
View File
@@ -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<string, string>).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,
+2 -2
View File
@@ -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'
+31 -18
View File
@@ -16,6 +16,7 @@ import { KNOWN_COUNTRIES } from './known-countries';
import { cityFromAddress } from './city-from-address';
import { transferEndpointIds } from './transfer-endpoints';
import type { FlightEndpointRow } from './transfer-endpoints';
import { countryVisitDates } from './visit-dates';
import { haversineKm } from '../common/geo';
/**
@@ -262,21 +263,16 @@ export class AtlasService {
}
}
const countries = [...countrySet.values()].map((c) => {
const countryTrips = trips.filter((t) => c.tripIds.has(t.id));
const dates = countryTrips
.map((t) => t.start_date)
.filter(Boolean)
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
return {
code: c.code,
placeCount: c.places.length,
tripCount: c.tripIds.size,
firstVisit: dates[0] || null,
lastVisit: dates[dates.length - 1] || null,
status: c.status,
};
});
// The dates are filled in at the end, once a manual mark or a booking has had its
// say on the status: they come only from the trips that match that final status.
const countries = [...countrySet.values()].map((c) => ({
code: c.code,
placeCount: c.places.length,
tripCount: c.tripIds.size,
firstVisit: null as string | null,
lastVisit: null as string | null,
status: c.status,
}));
const citySet = new Set<string>();
for (const place of places) {
@@ -367,14 +363,17 @@ export class AtlasService {
// Collapse to one entry per coordinate before resolving countries —
// getCountryFromCoords is a point-in-polygon scan and used to run once per point.
const endpointStatus = new Map<string, { lat: number; lng: number; status: VisitStatus }>();
const endpointStatus = new Map<string, { lat: number; lng: number; status: VisitStatus; tripIds: Set<number> }>();
for (const e of endpoints) {
const status = tripStatus.get(e.trip_id) ?? 'idea';
const key = `${e.lat},${e.lng}`;
const seen = endpointStatus.get(key);
if (seen) seen.status = strongerVisitStatus(seen.status, status);
else endpointStatus.set(key, { lat: e.lat, lng: e.lng, status });
if (seen) {
seen.status = strongerVisitStatus(seen.status, status);
seen.tripIds.add(e.trip_id);
} else endpointStatus.set(key, { lat: e.lat, lng: e.lng, status, tripIds: new Set([e.trip_id]) });
}
const bookingTripIds = new Map<string, Set<number>>();
for (const e of endpointStatus.values()) {
const code = getCountryFromCoords(e.lat, e.lng);
if (!code || hidden.has(code)) continue;
@@ -382,6 +381,20 @@ export class AtlasService {
if (existing) existing.status = strongerVisitStatus(existing.status, e.status);
else
countries.push({ code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null, status: e.status });
const ids = bookingTripIds.get(code) ?? new Set<number>();
for (const id of e.tripIds) ids.add(id);
bookingTripIds.set(code, ids);
}
// A booking can be what makes a country with places visited, so its trip dates the
// country as well. A country reached by bookings alone has no trip in its tooltip
// and stays without dates.
for (const c of countries) {
const fromPlaces = countrySet.get(c.code);
if (!fromPlaces) continue;
const fromBookings = bookingTripIds.get(c.code);
const dated = trips.filter((t) => fromPlaces.tripIds.has(t.id) || fromBookings?.has(t.id));
Object.assign(c, countryVisitDates(dated, c.status, now));
}
// Everything below counts actual visits only. countries[] still carries planned and
+40
View File
@@ -0,0 +1,40 @@
/**
* The "First trip / Last trip" dates Atlas shows for a country (#1535).
*
* Both used to be the earliest and the latest start_date of every trip with a
* place in the country. A single trip therefore showed its departure month twice,
* the month you came home never appeared, and a trip you had only booked could
* become the last visit of a country you had already been to.
*
* Now only the trips that give the country its status count: the visited ones for
* a visited country, the planned ones for a planned country. firstVisit is where
* the earliest of them starts and lastVisit where the latest ends. A trip you are
* still on ends today, not on the day you plan to fly back.
*
* Dates stay 'YYYY-MM-DD' strings and are compared as such, like tripVisitStatus
* does, so no timezone can shift them on the way.
*/
import { tripVisitStatus, type VisitStatus } from '@trek/shared';
export interface DatedTrip {
start_date?: string | null;
end_date?: string | null;
}
export function countryVisitDates(
trips: DatedTrip[],
status: VisitStatus,
today: string,
): { firstVisit: string | null; lastVisit: string | null } {
let firstVisit: string | null = null;
let lastVisit: string | null = null;
for (const trip of trips) {
const start = trip.start_date || trip.end_date;
if (!start || tripVisitStatus(trip.start_date, trip.end_date, today) !== status) continue;
let end = trip.end_date && trip.end_date > start ? trip.end_date : start;
if (status === 'visited' && end > today) end = today;
if (!firstVisit || start < firstVisit) firstVisit = start;
if (!lastVisit || end > lastVisit) lastVisit = end;
}
return { firstVisit, lastVisit };
}
+3 -2
View File
@@ -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);
@@ -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,
});
});
});
@@ -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);
+1
View File
@@ -52,6 +52,7 @@ const atlas: TranslationStrings = {
'atlas.oceania': 'أوقيانوسيا',
'atlas.other': 'أخرى',
'atlas.firstVisit': 'أول رحلة',
'atlas.lastVisitLabel': 'آخر رحلة',
'atlas.tripSingular': 'رحلة',
'atlas.tripPlural': 'رحلات',
'atlas.placeVisited': 'مكان تمت زيارته',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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é',
+1
View File
@@ -52,6 +52,7 @@ const atlas: TranslationStrings = {
'atlas.oceania': 'Ωκεανία',
'atlas.other': 'Άλλο',
'atlas.firstVisit': 'Πρώτο ταξίδι',
'atlas.lastVisitLabel': 'Τελευταίο ταξίδι',
'atlas.tripSingular': 'Ταξίδι',
'atlas.tripPlural': 'Ταξίδια',
'atlas.placeVisited': 'Μέρος που επισκεφθήκατε',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -52,6 +52,7 @@ const atlas: TranslationStrings = {
'atlas.oceania': 'オセアニア',
'atlas.other': 'その他',
'atlas.firstVisit': '最初の旅行',
'atlas.lastVisitLabel': '最後の旅行',
'atlas.tripSingular': '旅行',
'atlas.tripPlural': '旅行',
'atlas.placeVisited': '訪問した場所',
+1
View File
@@ -52,6 +52,7 @@ const atlas: TranslationStrings = {
'atlas.oceania': '오세아니아',
'atlas.other': '기타',
'atlas.firstVisit': '첫 번째 여행',
'atlas.lastVisitLabel': '마지막 여행',
'atlas.tripSingular': '여행',
'atlas.tripPlural': '여행',
'atlas.placeVisited': '방문한 장소',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -28,6 +28,7 @@ const atlas: TranslationStrings = {
'atlas.oceania': 'Океания',
'atlas.other': 'Другое',
'atlas.firstVisit': 'Первая поездка',
'atlas.lastVisitLabel': 'Последняя поездка',
'atlas.tripSingular': 'Поездка',
'atlas.tripPlural': 'Поездки',
'atlas.placeVisited': 'Посещённое место',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -28,6 +28,7 @@ const atlas: TranslationStrings = {
'atlas.oceania': 'Океанія',
'atlas.other': 'Інше',
'atlas.firstVisit': 'Перша поїздка',
'atlas.lastVisitLabel': 'Остання поїздка',
'atlas.tripSingular': 'Поїздка',
'atlas.tripPlural': 'Поїздки',
'atlas.placeVisited': 'Відвідане місце',
+1
View File
@@ -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',
+1
View File
@@ -28,6 +28,7 @@ const atlas: TranslationStrings = {
'atlas.oceania': '大洋洲',
'atlas.other': '其他',
'atlas.firstVisit': '首次旅行',
'atlas.lastVisitLabel': '最近旅行',
'atlas.tripSingular': '次旅行',
'atlas.tripPlural': '次旅行',
'atlas.placeVisited': '個地點已訪問',
+1
View File
@@ -28,6 +28,7 @@ const atlas: TranslationStrings = {
'atlas.oceania': '大洋洲',
'atlas.other': '其他',
'atlas.firstVisit': '首次旅行',
'atlas.lastVisitLabel': '最近旅行',
'atlas.tripSingular': '次旅行',
'atlas.tripPlural': '次旅行',
'atlas.placeVisited': '个地点已访问',