diff --git a/client/src/mobile/screens/atlas/MAtlas.tsx b/client/src/mobile/screens/atlas/MAtlas.tsx index e6a22aaee..afddbf044 100644 --- a/client/src/mobile/screens/atlas/MAtlas.tsx +++ b/client/src/mobile/screens/atlas/MAtlas.tsx @@ -9,6 +9,8 @@ import MAtlasStatsCard from './MAtlasStatsCard' import MAtlasSearch from './MAtlasSearch' import MAtlasCountryPopup from './MAtlasCountryPopup' import MAtlasBucketSheet from './MAtlasBucketSheet' +import MToggle from '../../components/MToggle' +import { countryStatus } from '../../../pages/atlas/atlasModel' const removeBtnCls = 'mt-4 w-full rounded-full bg-[rgba(214,39,59,.12)] py-[11px] text-center text-[0.8125rem] font-bold text-[color:var(--m-st-danger)]' // theme-lint-disable — fixed status-danger tint @@ -29,6 +31,9 @@ export default function MAtlas() { regionTooltipRef, stats, countries, + visitedCountries, + showPlanned, + togglePlanned, bucketList, selectedCountry, countryDetail, @@ -36,6 +41,7 @@ export default function MAtlas() { select_country_from_search, atlas_country_options, } = atlas + const plannedCount = stats.totalCountriesPlanned || 0 const [searchOpen, setSearchOpen] = useState(false) const [bucketOpen, setBucketOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false) @@ -61,7 +67,12 @@ export default function MAtlas() { const suggestions = useMemo(() => { const seen = new Set() const list: { code: string; label: string }[] = [] - const visited = [...countries].sort((a, b) => (b.lastVisit || '').localeCompare(a.lastVisit || '')) + // Visited first, planned after — this list answers "where have I been", so a country + // you only booked a flight to shouldn't outrank one you actually saw. + const visited = [...countries].sort((a, b) => { + const rank = (c: typeof a) => (countryStatus(c) === 'visited' ? 0 : 1) + return rank(a) - rank(b) || (b.lastVisit || '').localeCompare(a.lastVisit || '') + }) for (const c of visited) { if (seen.has(c.code)) continue seen.add(c.code) @@ -98,14 +109,21 @@ export default function MAtlas() { {/* Full-width bucket-list button. The bottom nav makes a back button redundant on this main-nav screen, so the header spans the width. */} -
+
+ {/* Only worth the space once there is something planned to reveal. */} + {plannedCount > 0 && ( +
+ {t('atlas.planned')} + togglePlanned()} ariaLabel={t('atlas.showPlanned')} /> +
+ )}
@@ -115,7 +133,8 @@ export default function MAtlas() { onClose={() => setSearchOpen(false)} options={atlas_country_options} suggestions={suggestions} - isVisited={(code) => countries.some((c) => c.code === code)} + isVisited={(code) => visitedCountries.some((c) => c.code === code)} + isPlanned={(code) => countries.some((c) => c.code === code && countryStatus(c) !== 'visited')} isOnBucketList={(code) => bucketList.some((b) => b.country_code === code)} onSelect={(code) => { setSearchOpen(false) diff --git a/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx b/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx index 26a491b5b..5d9afe0d6 100644 --- a/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx +++ b/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx @@ -3,6 +3,7 @@ import { ChevronRight, MapPin, Star } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import apiClient from '../../../api/client' import { continentForCountry } from '@trek/shared' +import { withCountryMarkedVisited } from '../../../pages/atlas/atlasModel' import { getApiErrorMessage } from '../../../types' import { useToast } from '../../../components/shared/Toast' import MSheet from '../../components/MSheet' @@ -78,16 +79,7 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) { const { code } = confirmAction try { await apiClient.post(`/addons/atlas/country/${code}/mark`) - 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 }, - } - }) + setData((prev) => (prev ? withCountryMarkedVisited(prev, code) : prev)) } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) } @@ -103,18 +95,9 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) { setVisitedRegions((prev) => { const existing = prev[countryCode] || [] if (existing.find((r) => r.code === regionCode)) return prev - return { ...prev, [countryCode]: [...existing, { code: regionCode, name: regionName, placeCount: 0, manuallyMarked: true }] } - }) - setData((prev) => { - if (!prev || prev.countries.find((c) => c.code === countryCode)) return prev - 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 }, - } + return { ...prev, [countryCode]: [...existing, { code: regionCode, name: regionName, placeCount: 0, status: 'visited' as const, manuallyMarked: true }] } }) + setData((prev) => (prev ? withCountryMarkedVisited(prev, countryCode) : prev)) } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) } diff --git a/client/src/mobile/screens/atlas/MAtlasSearch.tsx b/client/src/mobile/screens/atlas/MAtlasSearch.tsx index 8c676e81e..703e7700c 100644 --- a/client/src/mobile/screens/atlas/MAtlasSearch.tsx +++ b/client/src/mobile/screens/atlas/MAtlasSearch.tsx @@ -15,6 +15,8 @@ interface MAtlasSearchProps { /** Shown while the query is empty: recently visited + bucket countries. */ suggestions: CountryOption[] isVisited: (code: string) => boolean + /** Countries that only appear via a trip which hasn't started yet (#1048). */ + isPlanned: (code: string) => boolean isOnBucketList: (code: string) => boolean onSelect: (code: string) => void } @@ -32,6 +34,7 @@ export default function MAtlasSearch({ options, suggestions, isVisited, + isPlanned, isOnBucketList, onSelect, }: MAtlasSearchProps) { @@ -91,6 +94,8 @@ export default function MAtlasSearch({ {r.label} {isVisited(r.code) ? ( {t('mobileAtlas.visited')} + ) : isPlanned(r.code) ? ( + {t('mobileAtlas.planned')} ) : isOnBucketList(r.code) ? ( {t('atlas.bucketTab')} ) : null} diff --git a/client/src/mobile/screens/atlas/MAtlasStatsCard.tsx b/client/src/mobile/screens/atlas/MAtlasStatsCard.tsx index eb0000c08..487a66472 100644 --- a/client/src/mobile/screens/atlas/MAtlasStatsCard.tsx +++ b/client/src/mobile/screens/atlas/MAtlasStatsCard.tsx @@ -19,13 +19,21 @@ export default function MAtlasStatsCard({ stats }: MAtlasStatsCardProps) { [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')], ] + // All five columns are spoken for, so the planned count rides along with the country + // number as a superscript rather than claiming a sixth column. + const planned = stats.totalCountriesPlanned || 0 return (
- {cols.map(([n, l]) => ( + {cols.map(([n, l], i) => (
-
{n}
+
+ {n} + {i === 0 && planned > 0 && ( + +{planned} + )} +
{l}
))} diff --git a/client/tests/unit/mobile/atlas/MAtlas.test.tsx b/client/tests/unit/mobile/atlas/MAtlas.test.tsx index 95a8e755f..ac7c210db 100644 --- a/client/tests/unit/mobile/atlas/MAtlas.test.tsx +++ b/client/tests/unit/mobile/atlas/MAtlas.test.tsx @@ -5,7 +5,7 @@ import type { AtlasController } from '../../../../src/mobile/screens/atlas/atlas import type { CountryDetail } from '../../../../src/pages/atlas/atlasModel'; import MAtlas from '../../../../src/mobile/screens/atlas/MAtlas'; -// FE-MOB-ATLASSCR-001 to FE-MOB-ATLASSCR-016 +// FE-MOB-ATLASSCR-001 to FE-MOB-ATLASSCR-019 const mocks = vi.hoisted(() => ({ atlas: {} as AtlasController })); @@ -220,4 +220,45 @@ describe('MAtlas', () => { const rows = screen.getAllByRole('button').filter((b) => b.querySelector('img')); expect(rows.map((r) => r.textContent)).toEqual(['FranceVisited', 'JapanVisited']); }); + + it('FE-MOB-ATLASSCR-017: the planned pill stays away while nothing is planned', () => { + render(); + + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + expect(screen.queryByText('atlas.planned')).not.toBeInTheDocument(); + }); + + it('FE-MOB-ATLASSCR-018: a planned country brings up the pill and its switch flips the layer', () => { + const atlas = setAtlas({ + data: buildAtlasData({ + stats: { totalTrips: 4, totalPlaces: 21, totalCountries: 7, totalDays: 30, totalCities: 9, totalCountriesPlanned: 2 }, + }), + }); + render(); + + expect(screen.getByText('atlas.planned')).toBeInTheDocument(); + const toggle = screen.getByRole('switch', { name: 'atlas.showPlanned' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + + fireEvent.click(toggle); + expect(atlas.togglePlanned).toHaveBeenCalled(); + }); + + it('FE-MOB-ATLASSCR-019: suggestions rank the countries you have been to above the planned ones', async () => { + setAtlas({ + data: buildAtlasData({ + countries: [ + { code: 'JP', tripCount: 1, placeCount: 0, lastVisit: '2099-08-01', status: 'planned' }, + { code: 'FR', tripCount: 1, placeCount: 1, lastVisit: '2022-01-01', status: 'visited' }, + ], + stats: { totalTrips: 2, totalPlaces: 1, totalCountries: 1, totalDays: 4, totalCountriesPlanned: 1 }, + }), + }); + render(, { initialEntries: ['/atlas?search=1'] }); + + await screen.findByPlaceholderText('Search a country...'); + const rows = screen.getAllByRole('button').filter((b) => b.querySelector('img')); + // Japan has the later date but is only planned, so France leads and keeps "Visited". + expect(rows.map((r) => r.textContent)).toEqual(['FranceVisited', 'JapanPlanned']); + }); }); diff --git a/client/tests/unit/mobile/atlas/MAtlasCountryPopup.test.tsx b/client/tests/unit/mobile/atlas/MAtlasCountryPopup.test.tsx index f883c8949..2dd18fa51 100644 --- a/client/tests/unit/mobile/atlas/MAtlasCountryPopup.test.tsx +++ b/client/tests/unit/mobile/atlas/MAtlasCountryPopup.test.tsx @@ -124,7 +124,7 @@ describe('MAtlasCountryPopup', () => { await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); const regions = atlas.visitedRegions as VisitedRegions; - expect(regions.ES).toEqual([{ code: 'ES-GA', name: 'Galicia', placeCount: 0, manuallyMarked: true }]); + expect(regions.ES).toEqual([{ code: 'ES-GA', name: 'Galicia', placeCount: 0, status: 'visited', manuallyMarked: true }]); const data = atlas.data as AtlasData; expect(data.countries.map((c) => c.code)).toContain('ES'); expect(data.stats.totalCountries).toBe(2); diff --git a/client/tests/unit/mobile/atlas/MAtlasSearch.test.tsx b/client/tests/unit/mobile/atlas/MAtlasSearch.test.tsx index bae0f0f6e..3a002e813 100644 --- a/client/tests/unit/mobile/atlas/MAtlasSearch.test.tsx +++ b/client/tests/unit/mobile/atlas/MAtlasSearch.test.tsx @@ -19,6 +19,7 @@ function renderSearch(overrides: Partial false, + isPlanned: () => false, isOnBucketList: () => false, onSelect: () => {}, ...overrides, @@ -53,4 +54,23 @@ describe('MAtlasSearch', () => { renderSearch({ open: false }); expect(screen.queryByPlaceholderText('Search a country...')).not.toBeInTheDocument(); }); + + it('FE-MOB-ATLAS-004: a country you only plan to visit is labelled planned, not visited (#1048)', async () => { + renderSearch({ isPlanned: (code) => code === 'DE' }); + + await userEvent.type(screen.getByPlaceholderText('Search a country...'), 'germ'); + + expect(screen.getByText('Planned')).toBeInTheDocument(); + expect(screen.queryByText('Visited')).not.toBeInTheDocument(); + }); + + it('FE-MOB-ATLAS-005: having been there wins over the planned and bucket labels', async () => { + renderSearch({ isVisited: () => true, isPlanned: () => true, isOnBucketList: () => true }); + + await userEvent.type(screen.getByPlaceholderText('Search a country...'), 'germ'); + + expect(screen.getByText('Visited')).toBeInTheDocument(); + expect(screen.queryByText('Planned')).not.toBeInTheDocument(); + expect(screen.queryByText('Bucket List')).not.toBeInTheDocument(); + }); }); diff --git a/client/tests/unit/mobile/atlas/MAtlasStatsCard.test.tsx b/client/tests/unit/mobile/atlas/MAtlasStatsCard.test.tsx index 15736207e..307897081 100644 --- a/client/tests/unit/mobile/atlas/MAtlasStatsCard.test.tsx +++ b/client/tests/unit/mobile/atlas/MAtlasStatsCard.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '../../../helpers/render'; import MAtlasStatsCard from '../../../../src/mobile/screens/atlas/MAtlasStatsCard'; -// FE-MOB-ATLAS-010 onwards +// FE-MOB-ATLAS-010 to FE-MOB-ATLAS-012 const stats = { totalCountries: 3, totalTrips: 2, totalPlaces: 125, totalCities: 31, totalDays: 26 }; @@ -16,5 +16,23 @@ describe('MAtlasStatsCard', () => { for (const value of ['3', '2', '125', '31', '26']) { expect(screen.getByText(value)).toBeInTheDocument(); } + // No planned countries, no superscript — the column reads as a plain number. + expect(screen.getByText('Countries').parentElement).toHaveTextContent(/^3Countries$/); + }); + + it('FE-MOB-ATLAS-011: hangs the planned countries off the country total as a superscript (#1048)', () => { + render(); + + expect(screen.getByText('+4')).toBeInTheDocument(); + expect(screen.getByText('Countries').parentElement).toHaveTextContent(/^3\+4Countries$/); + // Only the first column carries it — the other four stay untouched. + expect(screen.getByText('Trips').parentElement).toHaveTextContent(/^2Trips$/); + }); + + it('FE-MOB-ATLAS-012: a zero planned count is left off entirely', () => { + render(); + + expect(screen.queryByText('+0')).not.toBeInTheDocument(); + expect(screen.getByText('Countries').parentElement).toHaveTextContent(/^3Countries$/); }); });