From 71c1683bb355c5ddd370ce46dedaafbb1d687bb1 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 5 Apr 2026 03:17:33 +0200 Subject: [PATCH] feat(atlas): mark sub-national regions as visited with cascade behavior - Add visited_regions table migration - Mark/unmark region endpoints with auto-mark parent country - Unmark country cascades to its regions; unmark last region cascades to country - Region modal with mark/unmark flow and bucket list shortcut - Viewport-based lazy loading of region GeoJSON at zoom >= 6 - i18n: add atlas.markRegionVisitedHint and atlas.confirmUnmarkRegion across all 13 locales --- client/src/i18n/translations/ar.ts | 2 + client/src/i18n/translations/br.ts | 2 + client/src/i18n/translations/cs.ts | 2 + client/src/i18n/translations/de.ts | 2 + client/src/i18n/translations/en.ts | 2 + client/src/i18n/translations/es.ts | 2 + client/src/i18n/translations/fr.ts | 2 + client/src/i18n/translations/hu.ts | 2 + client/src/i18n/translations/it.ts | 2 + client/src/i18n/translations/nl.ts | 2 + client/src/i18n/translations/pl.ts | 2 + client/src/i18n/translations/ru.ts | 2 + client/src/i18n/translations/zh.ts | 2 + client/src/pages/AtlasPage.tsx | 253 +++++++++++++++++++++++----- server/src/db/migrations.ts | 14 ++ server/src/routes/atlas.ts | 16 ++ server/src/services/atlasService.ts | 37 +++- 17 files changed, 301 insertions(+), 45 deletions(-) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index f0b6596..3a357a9 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -690,8 +690,10 @@ const ar: Record = { 'atlas.unmark': 'إزالة', 'atlas.confirmMark': 'تعيين هذا البلد كمُزار؟', 'atlas.confirmUnmark': 'إزالة هذا البلد من قائمة المُزارة؟', + 'atlas.confirmUnmarkRegion': 'إزالة هذه المنطقة من قائمة المُزارة؟', 'atlas.markVisited': 'تعيين كمُزار', 'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة', + 'atlas.markRegionVisitedHint': 'إضافة هذه المنطقة إلى قائمة المُزارة', 'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات', 'atlas.addPoi': 'إضافة مكان', 'atlas.searchCountry': 'ابحث عن دولة...', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index aad3bc0..76ed4d5 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -672,8 +672,10 @@ const br: Record = { 'atlas.unmark': 'Remover', 'atlas.confirmMark': 'Marcar este país como visitado?', 'atlas.confirmUnmark': 'Remover este país da lista de visitados?', + 'atlas.confirmUnmarkRegion': 'Remover esta região da lista de visitados?', 'atlas.markVisited': 'Marcar como visitado', 'atlas.markVisitedHint': 'Adicionar este país à lista de visitados', + 'atlas.markRegionVisitedHint': 'Adicionar esta região à lista de visitados', 'atlas.addToBucket': 'Adicionar à lista de desejos', 'atlas.addPoi': 'Adicionar lugar', 'atlas.searchCountry': 'Buscar um país...', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 97c2014..970ebb5 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -689,8 +689,10 @@ const cs: Record = { 'atlas.unmark': 'Odebrat', 'atlas.confirmMark': 'Označit tuto zemi jako navštívenou?', 'atlas.confirmUnmark': 'Odebrat tuto zemi ze seznamu navštívených?', + 'atlas.confirmUnmarkRegion': 'Odebrat tento region ze seznamu navštívených?', 'atlas.markVisited': 'Označit jako navštívené', 'atlas.markVisitedHint': 'Přidat tuto zemi do seznamu navštívených', + 'atlas.markRegionVisitedHint': 'Přidat tento region do seznamu navštívených', 'atlas.addToBucket': 'Přidat do seznamu přání (Bucket list)', 'atlas.addPoi': 'Přidat místo', 'atlas.bucketNamePlaceholder': 'Název (země, město, místo...)', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 1185128..23f17b6 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -688,8 +688,10 @@ const de: Record = { 'atlas.unmark': 'Entfernen', 'atlas.confirmMark': 'Dieses Land als besucht markieren?', 'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?', + 'atlas.confirmUnmarkRegion': 'Diese Region von der Liste entfernen?', 'atlas.markVisited': 'Als besucht markieren', 'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen', + 'atlas.markRegionVisitedHint': 'Diese Region zur besuchten Liste hinzufügen', 'atlas.addToBucket': 'Zur Bucket List', 'atlas.addPoi': 'Ort hinzufügen', 'atlas.searchCountry': 'Land suchen...', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 91077b0..9b19cbc 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -685,8 +685,10 @@ const en: Record = { 'atlas.unmark': 'Remove', 'atlas.confirmMark': 'Mark this country as visited?', 'atlas.confirmUnmark': 'Remove this country from your visited list?', + 'atlas.confirmUnmarkRegion': 'Remove this region from your visited list?', 'atlas.markVisited': 'Mark as visited', 'atlas.markVisitedHint': 'Add this country to your visited list', + 'atlas.markRegionVisitedHint': 'Add this region to your visited list', 'atlas.addToBucket': 'Add to bucket list', 'atlas.addPoi': 'Add place', 'atlas.searchCountry': 'Search a country...', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 3c5ee41..aed723a 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -700,8 +700,10 @@ const es: Record = { 'atlas.unmark': 'Eliminar', 'atlas.confirmMark': '¿Marcar este país como visitado?', 'atlas.confirmUnmark': '¿Eliminar este país de tu lista de visitados?', + 'atlas.confirmUnmarkRegion': '¿Eliminar esta región de tu lista de visitados?', 'atlas.markVisited': 'Marcar como visitado', 'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados', + 'atlas.markRegionVisitedHint': 'Añadir esta región a tu lista de visitados', 'atlas.addToBucket': 'Añadir a lista de deseos', 'atlas.addPoi': 'Añadir lugar', 'atlas.searchCountry': 'Buscar un país...', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 5334882..02781b4 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -723,8 +723,10 @@ const fr: Record = { 'atlas.unmark': 'Retirer', 'atlas.confirmMark': 'Marquer ce pays comme visité ?', 'atlas.confirmUnmark': 'Retirer ce pays de votre liste ?', + 'atlas.confirmUnmarkRegion': 'Retirer cette région de votre liste ?', 'atlas.markVisited': 'Marquer comme visité', 'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités', + 'atlas.markRegionVisitedHint': 'Ajouter cette région à votre liste de visités', 'atlas.addToBucket': 'Ajouter à la bucket list', 'atlas.addPoi': 'Ajouter un lieu', 'atlas.searchCountry': 'Rechercher un pays…', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 7017d67..1d3d845 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -688,8 +688,10 @@ const hu: Record = { 'atlas.unmark': 'Eltávolítás', 'atlas.confirmMark': 'Megjelölöd ezt az országot meglátogatottként?', 'atlas.confirmUnmark': 'Eltávolítod ezt az országot a meglátogatottak listájáról?', + 'atlas.confirmUnmarkRegion': 'Eltávolítod ezt a régiót a meglátogatottak listájáról?', 'atlas.markVisited': 'Megjelölés meglátogatottként', 'atlas.markVisitedHint': 'Ország hozzáadása a meglátogatottak listájához', + 'atlas.markRegionVisitedHint': 'Régió hozzáadása a meglátogatottak listájához', 'atlas.addToBucket': 'Hozzáadás a bakancslistához', 'atlas.addPoi': 'Hely hozzáadása', 'atlas.searchCountry': 'Ország keresése...', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index f74456b..b53f5cf 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -688,8 +688,10 @@ const it: Record = { 'atlas.unmark': 'Rimuovi', 'atlas.confirmMark': 'Segnare questo paese come visitato?', 'atlas.confirmUnmark': 'Rimuovere questo paese dalla tua lista dei visitati?', + 'atlas.confirmUnmarkRegion': 'Rimuovere questa regione dalla tua lista dei visitati?', 'atlas.markVisited': 'Segna come visitato', 'atlas.markVisitedHint': 'Aggiungi questo paese alla tua lista dei visitati', + 'atlas.markRegionVisitedHint': 'Aggiungi questa regione alla tua lista dei visitati', 'atlas.addToBucket': 'Aggiungi alla lista desideri', 'atlas.addPoi': 'Aggiungi luogo', 'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 3bcd4e7..7b9333f 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -723,8 +723,10 @@ const nl: Record = { 'atlas.unmark': 'Verwijderen', 'atlas.confirmMark': 'Dit land als bezocht markeren?', 'atlas.confirmUnmark': 'Dit land van je bezochte lijst verwijderen?', + 'atlas.confirmUnmarkRegion': 'Deze regio van je bezochte lijst verwijderen?', 'atlas.markVisited': 'Markeren als bezocht', 'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst', + 'atlas.markRegionVisitedHint': 'Deze regio toevoegen aan je bezochte lijst', 'atlas.addToBucket': 'Aan bucket list toevoegen', 'atlas.addPoi': 'Plaats toevoegen', 'atlas.searchCountry': 'Zoek een land...', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 031dbc9..6bbddbd 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -651,8 +651,10 @@ const pl: Record = { 'atlas.unmark': 'Usuń', 'atlas.confirmMark': 'Oznaczyć ten kraj jako odwiedzony?', 'atlas.confirmUnmark': 'Usunąć ten kraj z listy odwiedzonych?', + 'atlas.confirmUnmarkRegion': 'Usunąć ten region z listy odwiedzonych?', 'atlas.markVisited': 'Oznacz jako odwiedzony', 'atlas.markVisitedHint': 'Dodaj ten kraj do listy odwiedzonych', + 'atlas.markRegionVisitedHint': 'Dodaj ten region do listy odwiedzonych', 'atlas.addToBucket': 'Dodaj do listy marzeń', 'atlas.addPoi': 'Dodaj miejsce', 'atlas.bucketNamePlaceholder': 'Nazwa (kraj, miasto, miejsce...)', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index f961d08..6397439 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -723,8 +723,10 @@ const ru: Record = { 'atlas.unmark': 'Удалить', 'atlas.confirmMark': 'Отметить эту страну как посещённую?', 'atlas.confirmUnmark': 'Удалить эту страну из списка посещённых?', + 'atlas.confirmUnmarkRegion': 'Удалить этот регион из списка посещённых?', 'atlas.markVisited': 'Отметить как посещённую', 'atlas.markVisitedHint': 'Добавить эту страну в список посещённых', + 'atlas.markRegionVisitedHint': 'Добавить этот регион в список посещённых', 'atlas.addToBucket': 'В список желаний', 'atlas.addPoi': 'Добавить место', 'atlas.searchCountry': 'Поиск страны...', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 58026c6..ac038d6 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -723,8 +723,10 @@ const zh: Record = { 'atlas.unmark': '移除', 'atlas.confirmMark': '将此国家标记为已访问?', 'atlas.confirmUnmark': '从已访问列表中移除此国家?', + 'atlas.confirmUnmarkRegion': '从已访问列表中移除此地区?', 'atlas.markVisited': '标记为已访问', 'atlas.markVisitedHint': '将此国家添加到已访问列表', + 'atlas.markRegionVisitedHint': '将此地区添加到已访问列表', 'atlas.addToBucket': '添加到心愿单', 'atlas.addPoi': '添加地点', 'atlas.searchCountry': '搜索国家...', diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 051797b..ddff4ce 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -154,13 +154,16 @@ export default function AtlasPage(): React.ReactElement { const [selectedCountry, setSelectedCountry] = useState(null) const [countryDetail, setCountryDetail] = useState(null) const [geoData, setGeoData] = useState(null) - const [visitedRegions, setVisitedRegions] = useState>({}) + const [visitedRegions, setVisitedRegions] = useState>({}) const regionLayerRef = useRef(null) const regionGeoCache = useRef>({}) const [showRegions, setShowRegions] = useState(false) const [regionGeoLoaded, setRegionGeoLoaded] = useState(0) const regionTooltipRef = useRef(null) - const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null) + const loadCountryDetailRef = useRef<(code: string) => void>(() => {}) + const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {}) + const setConfirmActionRef = useRef(() => {}) + const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; code: string; name: string; regionCode?: string; countryName?: string } | null>(null) const [bucketMonth, setBucketMonth] = useState(0) const [bucketYear, setBucketYear] = useState(0) @@ -227,33 +230,41 @@ export default function AtlasPage(): React.ReactElement { .catch(() => {}) }, []) - // Load visited regions from server + per-country GeoJSON (once on mount) - const regionsLoadedRef = useRef(false) + // Load visited regions (geocoded from places/trips) — once on mount useEffect(() => { - if (regionsLoadedRef.current) return - regionsLoadedRef.current = true apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`) - .then(r => { - const regions = r.data?.regions || {} - setVisitedRegions(regions) - const countries = Object.keys(regions) - if (countries.length === 0) return - apiClient.get(`/addons/atlas/regions/geo?countries=${countries.join(',')}`) - .then(geoRes => { - const geo = geoRes.data - if (geo?.features) { - for (const c of countries) { - const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c) - regionGeoCache.current[c] = { type: 'FeatureCollection', features } - } - setRegionGeoLoaded(v => v + 1) - } - }) - .catch(() => {}) - }) + .then(r => setVisitedRegions(r.data?.regions || {})) .catch(() => {}) }, []) + // Load admin-1 GeoJSON for countries visible in the current viewport + const loadRegionsForViewportRef = useRef<() => void>(() => {}) + const loadRegionsForViewport = (): void => { + if (!mapInstance.current) return + const bounds = mapInstance.current.getBounds() + const toLoad: string[] = [] + for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) { + if (regionGeoCache.current[code]) continue + try { + if (bounds.intersects((layer as any).getBounds())) toLoad.push(code) + } catch {} + } + if (!toLoad.length) return + apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`) + .then(geoRes => { + const geo = geoRes.data + if (!geo?.features) return + let added = false + for (const c of toLoad) { + const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c) + if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true } + } + if (added) setRegionGeoLoaded(v => v + 1) + }) + .catch(() => {}) + } + loadRegionsForViewportRef.current = loadRegionsForViewport + // Initialize map — runs after loading is done and mapRef is available useEffect(() => { if (loading || !mapRef.current) return @@ -263,7 +274,7 @@ export default function AtlasPage(): React.ReactElement { center: [25, 0], zoom: 3, minZoom: 3, - maxZoom: 7, + maxZoom: 10, zoomControl: false, attributionControl: false, maxBounds: [[-90, -220], [90, 220]], @@ -279,7 +290,7 @@ export default function AtlasPage(): React.ReactElement { : 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png' L.tileLayer(tileUrl, { - maxZoom: 8, + maxZoom: 10, keepBuffer: 25, updateWhenZooming: true, updateWhenIdle: false, @@ -290,31 +301,47 @@ export default function AtlasPage(): React.ReactElement { // Preload adjacent zoom level tiles L.tileLayer(tileUrl, { - maxZoom: 8, + maxZoom: 10, keepBuffer: 10, opacity: 0, tileSize: 256, crossOrigin: true, }).addTo(map) - // Custom pane for region layer — below overlay (z-index 400) but above tiles + // Custom pane for region layer — above overlay (z-index 400) map.createPane('regionPane') - map.getPane('regionPane')!.style.zIndex = '399' + map.getPane('regionPane')!.style.zIndex = '401' mapInstance.current = map - // Zoom-based region switching — dim country layer directly + // Zoom-based region switching map.on('zoomend', () => { const z = map.getZoom() const shouldShow = z >= 5 setShowRegions(shouldShow) const overlayPane = map.getPane('overlayPane') - if (overlayPane) overlayPane.style.opacity = shouldShow ? '0.35' : '1' - const regionPane = map.getPane('regionPane') - if (regionPane) { - regionPane.style.opacity = shouldShow ? '1' : '0' - regionPane.style.pointerEvents = shouldShow ? 'auto' : 'none' + if (overlayPane) { + overlayPane.style.opacity = shouldShow ? '0.35' : '1' + overlayPane.style.pointerEvents = shouldShow ? 'none' : 'auto' } + if (shouldShow) { + // Re-add region layer if it was removed while zoomed out + if (regionLayerRef.current && !map.hasLayer(regionLayerRef.current)) { + regionLayerRef.current.addTo(map) + } + loadRegionsForViewportRef.current() + } else { + // Physically remove region layer so its SVG paths can't intercept events + if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none' + if (regionLayerRef.current && map.hasLayer(regionLayerRef.current)) { + regionLayerRef.current.resetStyle() + regionLayerRef.current.removeFrom(map) + } + } + }) + + map.on('moveend', () => { + if (map.getZoom() >= 6) loadRegionsForViewportRef.current() }) return () => { map.remove(); mapInstance.current = null } @@ -465,13 +492,11 @@ export default function AtlasPage(): React.ReactElement { return false } - // Only include visited region features (non-visited pass through to country layer) + // Include ALL region features — visited ones get colored fill, unvisited get outline only const allFeatures: any[] = [] for (const geo of Object.values(regionGeoCache.current)) { for (const f of geo.features) { - if (isVisitedFeature(f)) { - allFeatures.push(f) - } + allFeatures.push(f) } } if (allFeatures.length === 0) return @@ -495,26 +520,64 @@ export default function AtlasPage(): React.ReactElement { pane: 'regionPane', style: (feature) => { const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase() - return { + const visited = isVisitedFeature(feature) + return visited ? { fillColor: a2ColorMap[countryA2] || '#6366f1', fillOpacity: 0.85, color: dark ? '#888' : '#64748b', weight: 1.2, + } : { + fillColor: dark ? '#ffffff' : '#000000', + fillOpacity: 0.03, + color: dark ? '#555' : '#94a3b8', + weight: 1, } }, onEachFeature: (feature, layer) => { const regionName = feature?.properties?.name || '' const countryName = feature?.properties?.admin || '' const regionCode = feature?.properties?.iso_3166_2 || '' + const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase() + const visited = isVisitedFeature(feature) const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0 + layer.on('click', () => { + if (!countryA2) return + if (visited) { + const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode) + if (regionEntry?.manuallyMarked) { + setConfirmActionRef.current({ + type: 'unmark-region', + code: countryA2, + name: regionName, + regionCode, + countryName, + }) + } else { + loadCountryDetailRef.current(countryA2) + } + } else { + setConfirmActionRef.current({ + type: 'choose-region', + code: countryA2, // country A2 code — used for flag display + name: regionName, // region name — shown as heading + regionCode, + countryName, + }) + } + }) layer.on('mouseover', (e: any) => { - e.target.setStyle({ fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' }) + e.target.setStyle(visited + ? { fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' } + : { fillOpacity: 0.15, fillColor: dark ? '#818cf8' : '#4f46e5', weight: 1.5, color: dark ? '#818cf8' : '#4f46e5' } + ) const tt = regionTooltipRef.current if (tt) { tt.style.display = 'block' tt.style.left = e.originalEvent.clientX + 12 + 'px' tt.style.top = e.originalEvent.clientY - 10 + 'px' - tt.innerHTML = `
${regionName}
${countryName}
${count} ${count === 1 ? 'place' : 'places'}
` + tt.innerHTML = visited + ? `
${regionName}
${countryName}
${count} ${count === 1 ? 'place' : 'places'}
` + : `
${regionName}
${countryName}
` } }) layer.on('mousemove', (e: any) => { @@ -527,12 +590,18 @@ export default function AtlasPage(): React.ReactElement { if (tt) tt.style.display = 'none' }) }, - }).addTo(mapInstance.current) + }) + // Only add to map if currently in region mode — otherwise hold it ready for when user zooms in + if (mapInstance.current.getZoom() >= 6) { + regionLayerRef.current.addTo(mapInstance.current) + } }, [regionGeoLoaded, visitedRegions, dark, t]) const handleMarkCountry = (code: string, name: string): void => { setConfirmAction({ type: 'choose', code, name }) } + handleMarkCountryRef.current = handleMarkCountry + setConfirmActionRef.current = setConfirmAction const handleUnmarkCountry = (code: string): void => { const country = data?.countries.find(c => c.code === code) @@ -586,6 +655,12 @@ export default function AtlasPage(): React.ReactElement { stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) }, } }) + setVisitedRegions(prev => { + if (!prev[code]) return prev + const next = { ...prev } + delete next[code] + return next + }) } } @@ -663,6 +738,7 @@ export default function AtlasPage(): React.ReactElement { setCountryDetail(r.data) } catch { /* */ } } + loadCountryDetailRef.current = loadCountryDetail const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 } const countries = data?.countries || [] @@ -932,6 +1008,50 @@ export default function AtlasPage(): React.ReactElement { )} + {confirmAction.type === 'choose-region' && ( +
+ {confirmAction.countryName && ( +

{confirmAction.countryName}

+ )} + + +
+ )} + {confirmAction.type === 'unmark' && ( <>

{t('atlas.confirmUnmark')}

@@ -948,6 +1068,51 @@ export default function AtlasPage(): React.ReactElement { )} + {confirmAction.type === 'unmark-region' && ( + <> + {confirmAction.countryName && ( +

{confirmAction.countryName}

+ )} +

{t('atlas.confirmUnmarkRegion')}

+
+ + +
+ + )} + {confirmAction.type === 'bucket' && ( <>

{t('atlas.bucketWhen')}

@@ -978,7 +1143,7 @@ export default function AtlasPage(): React.ReactElement {
- diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f734b7d..f6ce2b0 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -563,6 +563,20 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code); `); }, + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS visited_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + region_code TEXT NOT NULL, + region_name TEXT NOT NULL, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, region_code) + ); + CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/atlas.ts b/server/src/routes/atlas.ts index 0b1dc48..d5af8d8 100644 --- a/server/src/routes/atlas.ts +++ b/server/src/routes/atlas.ts @@ -6,6 +6,8 @@ import { getCountryPlaces, markCountryVisited, unmarkCountryVisited, + markRegionVisited, + unmarkRegionVisited, getVisitedRegions, getRegionGeo, listBucketList, @@ -56,6 +58,20 @@ router.delete('/country/:code/mark', (req: Request, res: Response) => { res.json({ success: true }); }); +router.post('/region/:code/mark', (req: Request, res: Response) => { + const userId = (req as AuthRequest).user.id; + const { name, country_code } = req.body; + if (!name || !country_code) return res.status(400).json({ error: 'name and country_code are required' }); + markRegionVisited(userId, req.params.code.toUpperCase(), name, country_code.toUpperCase()); + res.json({ success: true }); +}); + +router.delete('/region/:code/mark', (req: Request, res: Response) => { + const userId = (req as AuthRequest).user.id; + unmarkRegionVisited(userId, req.params.code.toUpperCase()); + res.json({ success: true }); +}); + // ── Bucket List ───────────────────────────────────────────────────────────── router.get('/bucket-list', (req: Request, res: Response) => { diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts index c423ca9..93779dd 100644 --- a/server/src/services/atlasService.ts +++ b/server/src/services/atlasService.ts @@ -371,6 +371,32 @@ export function markCountryVisited(userId: number, code: string): void { export function unmarkCountryVisited(userId: number, code: string): void { db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code); + db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND country_code = ?').run(userId, code); +} + +// ── Mark / unmark region ──────────────────────────────────────────────────── + +export function listManuallyVisitedRegions(userId: number): { region_code: string; region_name: string; country_code: string }[] { + return db.prepare( + 'SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY created_at DESC' + ).all(userId) as { region_code: string; region_name: string; country_code: string }[]; +} + +export function markRegionVisited(userId: number, regionCode: string, regionName: string, countryCode: string): void { + db.prepare('INSERT OR IGNORE INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(userId, regionCode, regionName, countryCode); + // Auto-mark parent country if not already visited + db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, countryCode); +} + +export function unmarkRegionVisited(userId: number, regionCode: string): void { + const region = db.prepare('SELECT country_code FROM visited_regions WHERE user_id = ? AND region_code = ?').get(userId, regionCode) as { country_code: string } | undefined; + db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND region_code = ?').run(userId, regionCode); + if (region) { + const remaining = db.prepare('SELECT COUNT(*) as count FROM visited_regions WHERE user_id = ? AND country_code = ?').get(userId, region.country_code) as { count: number }; + if (remaining.count === 0) { + db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, region.country_code); + } + } } // ── Sub-national region resolution ──────────────────────────────────────── @@ -450,11 +476,20 @@ export async function getVisitedRegions(userId: number): Promise<{ regions: Reco } } - const result: Record = {}; + const result: Record = {}; for (const [country, regions] of Object.entries(regionMap)) { result[country] = [...regions.values()]; } + // Merge manually marked regions + const manualRegions = 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 }); + } + } + return { regions: result }; }