From 6019b3ed4ea193ba7148e88de627d684a15d0663 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 3 Aug 2026 19:40:11 +0200 Subject: [PATCH] feat(client): carry the planned/visited split into the mobile atlas The mobile screen shares useAtlas, so it inherits the switch state; it gets its own compact toggle next to the bucket-list button. Search now labels a planned country as such instead of calling it visited, the stats card hangs the planned count off the country total, and the suggestion list keeps places you have actually been at the top. --- client/src/mobile/screens/atlas/MAtlas.tsx | 27 ++++++++++-- .../screens/atlas/MAtlasCountryPopup.tsx | 25 ++--------- .../src/mobile/screens/atlas/MAtlasSearch.tsx | 5 +++ .../mobile/screens/atlas/MAtlasStatsCard.tsx | 12 +++++- .../tests/unit/mobile/atlas/MAtlas.test.tsx | 43 ++++++++++++++++++- .../mobile/atlas/MAtlasCountryPopup.test.tsx | 2 +- .../unit/mobile/atlas/MAtlasSearch.test.tsx | 20 +++++++++ .../mobile/atlas/MAtlasStatsCard.test.tsx | 20 ++++++++- 8 files changed, 124 insertions(+), 30 deletions(-) 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$/); }); });