mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user