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.
This commit is contained in:
Maurice
2026-08-03 19:40:11 +02:00
committed by Maurice
parent 558f348881
commit 6019b3ed4e
8 changed files with 124 additions and 30 deletions
+23 -4
View File
@@ -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<string>()
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. */}
<div className="absolute left-4 right-4 top-[var(--m-safe-top,12px)] z-[5]">
<div className="absolute left-4 right-4 top-[var(--m-safe-top,12px)] z-[5] flex items-center gap-2">
<button
type="button"
onClick={() => setBucketOpen(true)}
className="flex h-[38px] w-full items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-4 text-[0.78125rem] font-bold text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
className="flex h-[38px] flex-1 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-4 text-[0.78125rem] font-bold text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
>
{t('atlas.bucketTab')}
</button>
{/* Only worth the space once there is something planned to reveal. */}
{plannedCount > 0 && (
<div className="flex h-[38px] shrink-0 items-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-3 shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]">
<span className="text-[0.6875rem] font-bold text-m-ink">{t('atlas.planned')}</span>
<MToggle checked={showPlanned} onChange={() => togglePlanned()} ariaLabel={t('atlas.showPlanned')} />
</div>
)}
</div>
<MAtlasStatsCard stats={stats} />
@@ -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)
@@ -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')))
}
@@ -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({
<span className="min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{r.label}</span>
{isVisited(r.code) ? (
<span className={statusCls}>{t('mobileAtlas.visited')}</span>
) : isPlanned(r.code) ? (
<span className={statusCls}>{t('mobileAtlas.planned')}</span>
) : isOnBucketList(r.code) ? (
<span className={statusCls}>{t('atlas.bucketTab')}</span>
) : null}
@@ -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 (
<div className="absolute bottom-[calc(var(--bottom-nav-h,84px)+16px)] left-4 right-4 z-[5]">
<div className="flex w-full rounded-[22px] border border-[color:var(--m-shbr)] bg-[color:var(--m-sheet)] px-3 py-[14px] shadow-[0_18px_44px_-20px_rgba(0,0,0,.4)]">
{cols.map(([n, l]) => (
{cols.map(([n, l], i) => (
<div key={l} className="flex-1 text-center">
<div className="text-[1.375rem] font-extrabold leading-none tabular-nums text-m-ink">{n}</div>
<div className="text-[1.375rem] font-extrabold leading-none tabular-nums text-m-ink">
{n}
{i === 0 && planned > 0 && (
<span className="align-super text-[0.6875rem] font-bold text-m-faint">+{planned}</span>
)}
</div>
<div className="mt-1 font-geist text-[0.53125rem] font-bold uppercase tracking-[.06em] text-m-faint">{l}</div>
</div>
))}
+42 -1
View File
@@ -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(<MAtlas />);
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(<MAtlas />);
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(<MAtlas />, { 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']);
});
});
@@ -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);
@@ -19,6 +19,7 @@ function renderSearch(overrides: Partial<React.ComponentProps<typeof MAtlasSearc
options,
suggestions: [{ code: 'JP', label: 'Japan' }],
isVisited: () => 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();
});
});
@@ -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(<MAtlasStatsCard stats={{ ...stats, totalCountriesPlanned: 4 }} />);
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(<MAtlasStatsCard stats={{ ...stats, totalCountriesPlanned: 0 }} />);
expect(screen.queryByText('+0')).not.toBeInTheDocument();
expect(screen.getByText('Countries').parentElement).toHaveTextContent(/^3Countries$/);
});
});