From 85e017ff85b6145c3ed34b4d7d3ac5d7152bc4a0 Mon Sep 17 00:00:00 2001
From: gfrcsd <23112101+gfrcsd@users.noreply.github.com>
Date: Sun, 12 Apr 2026 13:47:10 +0100
Subject: [PATCH 1/2] fix(atlas): add A3 fallback when ISO_A2 is invalid
---
client/src/pages/AtlasPage.test.tsx | 44 +++++++++++++++++++++++++++++
client/src/pages/AtlasPage.tsx | 11 ++++++--
2 files changed, 53 insertions(+), 2 deletions(-)
diff --git a/client/src/pages/AtlasPage.test.tsx b/client/src/pages/AtlasPage.test.tsx
index b18d2563..7bcf4be4 100644
--- a/client/src/pages/AtlasPage.test.tsx
+++ b/client/src/pages/AtlasPage.test.tsx
@@ -127,6 +127,23 @@ const geoJsonWithFR = {
],
};
+const geoJsonWithFranceA3Fallback = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ properties: {
+ ISO_A2: '-99',
+ ADM0_A3: 'FRA',
+ ISO_A3: 'FRA',
+ NAME: 'France',
+ ADMIN: 'France',
+ },
+ geometry: null,
+ },
+ ],
+};
+
// ── Atlas API response fixture ────────────────────────────────────────────────
const atlasStatsResponse = {
countries: [{ code: 'FR', tripCount: 2, placeCount: 5, firstVisit: '2023-01-01', lastVisit: '2024-06-01' }],
@@ -1323,6 +1340,33 @@ describe('AtlasPage', () => {
});
});
+ describe('FE-PAGE-ATLAS-041: country search falls back from A3 when ISO_A2 is invalid', () => {
+ it('returns France in search results when GeoJSON provides ADM0_A3 but ISO_A2 is -99', async () => {
+ vi.spyOn(global, 'fetch').mockImplementation((url) => {
+ const urlStr = String(url);
+ if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFranceA3Fallback) } as Response);
+ }
+ return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
+ });
+
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText(/search a country/i)).toBeInTheDocument();
+ });
+
+ const searchInput = screen.getByPlaceholderText(/search a country/i);
+ await user.type(searchInput, 'france');
+
+ await waitFor(() => {
+ const franceButton = screen.getAllByRole('button').find((button) => button.textContent?.includes('France'));
+ expect(franceButton).toBeTruthy();
+ });
+ });
+ });
+
describe('FE-PAGE-ATLAS-042: bucket form submit with actual name value', () => {
it('submitting bucket form with a non-empty name covers handleAddBucketItem', async () => {
server.use(
diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx
index 76f40690..41fd6443 100644
--- a/client/src/pages/AtlasPage.tsx
+++ b/client/src/pages/AtlasPage.tsx
@@ -189,8 +189,15 @@ export default function AtlasPage(): React.ReactElement {
const opts: { code: string; label: string }[] = []
const seen = new Set()
for (const f of (geoData as any).features || []) {
- const a2 = f?.properties?.ISO_A2
- if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) continue
+ let a2 = f?.properties?.ISO_A2
+ if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) {
+ const a3 = f?.properties?.ADM0_A3 || f?.properties?.ISO_A3 || f?.properties?.['ISO3166-1-Alpha-3'] || null
+ if (a3 && a3 !== '-99') {
+ const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3)
+ a2 = a3ToA2Entry ? a3ToA2Entry[0] : null
+ }
+ }
+ if (!a2 || typeof a2 !== 'string' || a2.length !== 2) continue
if (seen.has(a2)) continue
seen.add(a2)
const label = String(resolveName(a2) || f?.properties?.NAME || f?.properties?.ADMIN || a2)
From 33bb2c686308a649c792f6572f7bedec758bff07 Mon Sep 17 00:00:00 2001
From: jubnl
Date: Wed, 15 Apr 2026 03:31:19 +0200
Subject: [PATCH 2/2] fix(atlas): clean up A2_TO_A3 table and add A3-fallback
Norway test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Collapse A2_TO_A3_BASE + let A2_TO_A3 into a single const declaration;
the _BASE copy was vestigial (never read after the clone)
- Add a comment explaining the table's two sources and the load-bearing
invariant: countries whose Natural Earth record has ISO_A2='-99'
(France, Norway) must be listed here since the runtime augmentation
loop skips those features
- Refactor the France-only A3-fallback test fixture into a factory helper
and extend FE-PAGE-ATLAS-041 with a Norway (NOR) case via it.each
- Improve atlas_country_options useMemo: rename a2 → resolvedA2 for
clarity, precompute the A3→A2 reverse-lookup Map once per geoData
change instead of O(n) Object.entries().find() per feature
---
client/src/pages/AtlasPage.test.tsx | 25 +++++++++++---------
client/src/pages/AtlasPage.tsx | 36 +++++++++++++++++------------
2 files changed, 35 insertions(+), 26 deletions(-)
diff --git a/client/src/pages/AtlasPage.test.tsx b/client/src/pages/AtlasPage.test.tsx
index 7bcf4be4..72cc0756 100644
--- a/client/src/pages/AtlasPage.test.tsx
+++ b/client/src/pages/AtlasPage.test.tsx
@@ -127,22 +127,22 @@ const geoJsonWithFR = {
],
};
-const geoJsonWithFranceA3Fallback = {
+const makeGeoJsonWithA3Fallback = (a3: string, name: string) => ({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
ISO_A2: '-99',
- ADM0_A3: 'FRA',
- ISO_A3: 'FRA',
- NAME: 'France',
- ADMIN: 'France',
+ ADM0_A3: a3,
+ ISO_A3: a3,
+ NAME: name,
+ ADMIN: name,
},
geometry: null,
},
],
-};
+});
// ── Atlas API response fixture ────────────────────────────────────────────────
const atlasStatsResponse = {
@@ -1341,11 +1341,14 @@ describe('AtlasPage', () => {
});
describe('FE-PAGE-ATLAS-041: country search falls back from A3 when ISO_A2 is invalid', () => {
- it('returns France in search results when GeoJSON provides ADM0_A3 but ISO_A2 is -99', async () => {
+ it.each([
+ { a3: 'FRA', name: 'France', query: 'france' },
+ { a3: 'NOR', name: 'Norway', query: 'norway' },
+ ])('returns $name in search results when GeoJSON provides ADM0_A3=$a3 but ISO_A2 is -99', async ({ a3, name, query }) => {
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFranceA3Fallback) } as Response);
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(makeGeoJsonWithA3Fallback(a3, name)) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
@@ -1358,11 +1361,11 @@ describe('AtlasPage', () => {
});
const searchInput = screen.getByPlaceholderText(/search a country/i);
- await user.type(searchInput, 'france');
+ await user.type(searchInput, query);
await waitFor(() => {
- const franceButton = screen.getAllByRole('button').find((button) => button.textContent?.includes('France'));
- expect(franceButton).toBeTruthy();
+ const countryButton = screen.getAllByRole('button').find((button) => button.textContent?.includes(name));
+ expect(countryButton).toBeTruthy();
});
});
});
diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx
index 85459b43..955a4110 100644
--- a/client/src/pages/AtlasPage.tsx
+++ b/client/src/pages/AtlasPage.tsx
@@ -109,10 +109,14 @@ function useCountryNames(language: string): (code: string) => string {
return resolver
}
-// Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3)
-// Built dynamically from GeoJSON + hardcoded fallbacks
-const A2_TO_A3_BASE: Record = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
-let A2_TO_A3: Record = { ...A2_TO_A3_BASE }
+// ISO-3166-1 alpha-2 → alpha-3 mapping. Two sources feed this table:
+// 1. Hardcoded entries below — REQUIRED for any country whose Natural Earth GeoJSON record
+// has ISO_A2='-99' (e.g. France=FRA, Norway=NOR). The runtime augmentation loop
+// (see geoData useEffect below) skips '-99' features, so those countries MUST be
+// listed here or the atlas_country_options A3-fallback will silently fail.
+// 2. Runtime augmentation — the geoData load effect adds entries for every feature
+// that has a valid ISO_A2, covering territories not present below.
+const A2_TO_A3: Record = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
export default function AtlasPage(): React.ReactElement {
const { t, language } = useTranslation()
@@ -186,22 +190,24 @@ export default function AtlasPage(): React.ReactElement {
const atlas_country_options = useMemo(() => {
if (!geoData) return []
+ // Precompute A3 → A2 reverse lookup once per geoData change instead of
+ // scanning A2_TO_A3 for every feature that needs the fallback.
+ const a3ToA2 = new Map()
+ for (const [a2Key, a3Val] of Object.entries(A2_TO_A3)) a3ToA2.set(a3Val, a2Key)
+
const opts: { code: string; label: string }[] = []
const seen = new Set()
for (const f of (geoData as any).features || []) {
- let a2 = f?.properties?.ISO_A2
- if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) {
+ const rawA2 = f?.properties?.ISO_A2
+ let resolvedA2: string | null = (typeof rawA2 === 'string' && rawA2.length === 2 && rawA2 !== '-99') ? rawA2 : null
+ if (!resolvedA2) {
const a3 = f?.properties?.ADM0_A3 || f?.properties?.ISO_A3 || f?.properties?.['ISO3166-1-Alpha-3'] || null
- if (a3 && a3 !== '-99') {
- const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3)
- a2 = a3ToA2Entry ? a3ToA2Entry[0] : null
- }
+ if (a3 && a3 !== '-99') resolvedA2 = a3ToA2.get(a3) ?? null
}
- if (!a2 || typeof a2 !== 'string' || a2.length !== 2) continue
- if (seen.has(a2)) continue
- seen.add(a2)
- const label = String(resolveName(a2) || f?.properties?.NAME || f?.properties?.ADMIN || a2)
- opts.push({ code: a2, label })
+ if (!resolvedA2 || seen.has(resolvedA2)) continue
+ seen.add(resolvedA2)
+ const label = String(resolveName(resolvedA2) || f?.properties?.NAME || f?.properties?.ADMIN || resolvedA2)
+ opts.push({ code: resolvedA2, label })
}
opts.sort((a, b) => a.label.localeCompare(b.label))
return opts