From 17245c5a8cf9ab83013a4b30be4d6f8ea4ca4d8e Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 17 Jun 2026 23:12:30 +0200 Subject: [PATCH] fix(atlas): keep the continent breakdown in sync on mark/unmark (#1225) The optimistic mark/unmark updates bumped the country total but never the per-continent counts, so the continent column froze until a full reload. Move the country to continent map into @trek/shared (single source for server and client) and adjust the matching continent count at every optimistic site: the country confirm flow plus the choose / region mark and region unmark handlers. --- client/src/pages/AtlasPage.tsx | 9 ++++++-- client/src/pages/atlas/useAtlas.ts | 5 +++++ server/src/services/atlasService.ts | 25 +-------------------- shared/src/atlas/atlas.schema.ts | 35 +++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 26 deletions(-) diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 13469e3f..4e8f824a 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -6,6 +6,7 @@ import CustomSelect from '../components/shared/CustomSelect' import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react' import type { TranslationFn } from '../types' import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel' +import { continentForCountry } from '@trek/shared' import { useAtlas } from './atlas/useAtlas' import AtlasCountrySearch from './atlas/AtlasCountrySearch' import { useToast } from '../components/shared/Toast' @@ -212,7 +213,8 @@ export default function AtlasPage(): React.ReactElement { await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`) setData(prev => { if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev - return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } } + const cont = continentForCountry(confirmAction.code) + return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } } }) } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) @@ -260,7 +262,8 @@ export default function AtlasPage(): React.ReactElement { }) setData(prev => { if (!prev || prev.countries.find(c => c.code === countryCode)) return prev - return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } } + const cont = continentForCountry(countryCode) + return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } } }) } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) @@ -339,10 +342,12 @@ export default function AtlasPage(): React.ReactElement { if (!c || c.placeCount > 0 || c.tripCount > 0) return prev const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked) if (remainingRegions.length > 0) return prev + const cont = continentForCountry(countryCode) return { ...prev, countries: prev.countries.filter(c => c.code !== countryCode), stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) }, + continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) }, } }) } catch (err) { diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts index 99d02868..ce2a9e7b 100644 --- a/client/src/pages/atlas/useAtlas.ts +++ b/client/src/pages/atlas/useAtlas.ts @@ -6,6 +6,7 @@ import apiClient, { mapsApi } from '../../api/client' import L from 'leaflet' import type { GeoJsonFeatureCollection } from '../../types' import { A2_TO_A3, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel' +import { continentForCountry } from '@trek/shared' function useCountryNames(language: string): (code: string) => string { const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code) @@ -582,10 +583,12 @@ export function useAtlas() { apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {}) setData(prev => { if (!prev || prev.countries.find(c => c.code === code)) return prev + const cont = continentForCountry(code) return { ...prev, countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, + continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 }, } }) } else { @@ -596,10 +599,12 @@ export function useAtlas() { if (!prev) return prev const c = prev.countries.find(c => c.code === code) if (!c || c.placeCount > 0 || c.tripCount > 0) return prev + const cont = continentForCountry(code) return { ...prev, countries: prev.countries.filter(c => c.code !== code), stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) }, + continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) }, } }) setVisitedRegions(prev => { diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts index 48a6df7a..5577492c 100644 --- a/server/src/services/atlasService.ts +++ b/server/src/services/atlasService.ts @@ -3,6 +3,7 @@ import path from 'path'; import zlib from 'zlib'; import { db } from '../db/database'; import { Trip, Place } from '../types'; +import { CONTINENT_MAP } from '@trek/shared'; // ── Bundled boundary GeoJSON (admin-0 countries + admin-1 regions) ───────── // @@ -168,30 +169,6 @@ export const NAME_TO_CODE: Record = { 'liechtenstein':'LI','gibraltar':'GI','puerto rico':'PR', }; -export const CONTINENT_MAP: Record = { - AF:'Asia',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia', - BA:'Europe',BD:'Asia',BF:'Africa',BH:'Asia',BI:'Africa',BJ:'Africa',BN:'Asia',BO:'South America', - BR:'South America',BE:'Europe',BG:'Europe',BW:'Africa', - CA:'North America',CD:'Africa',CG:'Africa',CI:'Africa',CL:'South America',CM:'Africa',CN:'Asia',CO:'South America', - CR:'North America',CU:'North America',CV:'Africa',CY:'Europe',HR:'Europe',CZ:'Europe', - DJ:'Africa',DK:'Europe',DO:'North America',EC:'South America',EG:'Africa',EE:'Europe',ER:'Africa',ET:'Africa', - FI:'Europe',FR:'Europe',DE:'Europe',GE:'Asia',GH:'Africa',GN:'Africa',GR:'Europe',GT:'North America', - HN:'North America',HT:'North America',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',IR:'Asia',IQ:'Asia', - IE:'Europe',IL:'Asia',IT:'Europe',JM:'North America',JO:'Asia',JP:'Asia',KE:'Africa',KG:'Asia',KH:'Asia', - KR:'Asia',KW:'Asia',KZ:'Asia',LA:'Asia',LB:'Asia',LK:'Asia',LV:'Europe',LT:'Europe',LU:'Europe',LY:'Africa', - MA:'Africa',MD:'Europe',ME:'Europe',MG:'Africa',MK:'Europe',ML:'Africa',MM:'Asia',MN:'Asia',MR:'Africa', - MT:'Europe',MU:'Africa',MV:'Asia',MW:'Africa',MY:'Asia',MX:'North America',MZ:'Africa', - NA:'Africa',NE:'Africa',NI:'North America',NL:'Europe',NP:'Asia',NZ:'Oceania',NO:'Europe',OM:'Asia', - PA:'North America',PG:'Oceania',PK:'Asia',PE:'South America',PH:'Asia',PL:'Europe',PS:'Asia', - PT:'Europe',PY:'South America',QA:'Asia',RO:'Europe',RU:'Europe',RW:'Africa',SA:'Asia',SC:'Africa', - SD:'Africa',SG:'Asia',SI:'Europe',SK:'Europe',SN:'Africa',SO:'Africa',RS:'Europe',SV:'North America', - SY:'Asia',TG:'Africa',TJ:'Asia',TM:'Asia',TN:'Africa',TT:'North America',TW:'Asia',TZ:'Africa', - ZA:'Africa',SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',UG:'Africa',UY:'South America', - UZ:'Asia',VE:'South America',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',XK:'Europe', - YE:'Asia',ZM:'Africa',ZW:'Africa',NG:'Africa', - HK:'Asia',MO:'Asia',SM:'Europe',VA:'Europe',MC:'Europe',LI:'Europe',GI:'Europe',PR:'North America', -}; - // ── Geocoding helpers ─────────────────────────────────────────────────────── let lastNominatimCall = 0; diff --git a/shared/src/atlas/atlas.schema.ts b/shared/src/atlas/atlas.schema.ts index 992c92fd..3ecdb54f 100644 --- a/shared/src/atlas/atlas.schema.ts +++ b/shared/src/atlas/atlas.schema.ts @@ -59,3 +59,38 @@ export const regionGeoSchema = z.object({ features: z.array(z.unknown()), }); export type RegionGeo = z.infer; + +/** + * ISO 3166-1 alpha-2 country code → continent. Single source of truth for the + * Atlas continent breakdown, used by the server (stats aggregation) and the + * client (keeping the per-continent counts in sync on optimistic mark/unmark). + */ +export const CONTINENT_MAP: Record = { + AF:'Asia',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia', + BA:'Europe',BD:'Asia',BF:'Africa',BH:'Asia',BI:'Africa',BJ:'Africa',BN:'Asia',BO:'South America', + BR:'South America',BE:'Europe',BG:'Europe',BW:'Africa', + CA:'North America',CD:'Africa',CG:'Africa',CI:'Africa',CL:'South America',CM:'Africa',CN:'Asia',CO:'South America', + CR:'North America',CU:'North America',CV:'Africa',CY:'Europe',HR:'Europe',CZ:'Europe', + DJ:'Africa',DK:'Europe',DO:'North America',EC:'South America',EG:'Africa',EE:'Europe',ER:'Africa',ET:'Africa', + FI:'Europe',FR:'Europe',DE:'Europe',GE:'Asia',GH:'Africa',GN:'Africa',GR:'Europe',GT:'North America', + HN:'North America',HT:'North America',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',IR:'Asia',IQ:'Asia', + IE:'Europe',IL:'Asia',IT:'Europe',JM:'North America',JO:'Asia',JP:'Asia',KE:'Africa',KG:'Asia',KH:'Asia', + KR:'Asia',KW:'Asia',KZ:'Asia',LA:'Asia',LB:'Asia',LK:'Asia',LV:'Europe',LT:'Europe',LU:'Europe',LY:'Africa', + MA:'Africa',MD:'Europe',ME:'Europe',MG:'Africa',MK:'Europe',ML:'Africa',MM:'Asia',MN:'Asia',MR:'Africa', + MT:'Europe',MU:'Africa',MV:'Asia',MW:'Africa',MY:'Asia',MX:'North America',MZ:'Africa', + NA:'Africa',NE:'Africa',NI:'North America',NL:'Europe',NP:'Asia',NZ:'Oceania',NO:'Europe',OM:'Asia', + PA:'North America',PG:'Oceania',PK:'Asia',PE:'South America',PH:'Asia',PL:'Europe',PS:'Asia', + PT:'Europe',PY:'South America',QA:'Asia',RO:'Europe',RU:'Europe',RW:'Africa',SA:'Asia',SC:'Africa', + SD:'Africa',SG:'Asia',SI:'Europe',SK:'Europe',SN:'Africa',SO:'Africa',RS:'Europe',SV:'North America', + SY:'Asia',TG:'Africa',TJ:'Asia',TM:'Asia',TN:'Africa',TT:'North America',TW:'Asia',TZ:'Africa', + ZA:'Africa',SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',UG:'Africa',UY:'South America', + UZ:'Asia',VE:'South America',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',XK:'Europe', + YE:'Asia',ZM:'Africa',ZW:'Africa',NG:'Africa', + HK:'Asia',MO:'Asia',SM:'Europe',VA:'Europe',MC:'Europe',LI:'Europe',GI:'Europe',PR:'North America', +}; + +/** Continent for an ISO alpha-2 country code; 'Other' when unknown. */ +export function continentForCountry(code: string | null | undefined): string { + if (!code) return 'Other'; + return CONTINENT_MAP[code.toUpperCase()] || 'Other'; +}